Lektion 17 - Tic-Tac-Toe objektorientiert

Erstellt von Frithjof Fri, 08 Feb 2008 22:19:00 GMT

Dies ist die letzte Lektion zu Tic-Tac-Toe. Das Spiel wird einen nicht unerheblichen Umbau erfahren: es wird objektorientiert werden. Das heißt aber nicht, dass es danach optimal und fertig ist. Software ist nie fertig (und meistens leider auch nicht immer optimal). Es gibt stets noch etwas zu verbessern oder neue Funktionalität, die man noch hinzufügen könnte. In den nächsten Lektionen warten aber noch mehr Abenteuer mit Ruby auf dich.

In der Theorie Lektion 7 hast du eine Vorgehensweise kennengelernt, wie man aus einem beschreibenden Text, die notwendigen Objekte und Methoden herausfinden kann. Nehmen wir eine Spielbeschreibung (hier die etwas umformulierte aus der ersten Lektion zu Tic-Tac-Toe) her und versuchen zunächst nur die Objekte (bzw. Klassen) daraus zu bestimmen.

Das Spielfeld des Spiels Tic-Tac-Toe besteht aus 3 mal 3 Feldern. Zwei Spieler setzen bei jedem Zug abwechselnd einen Spielstein, um als erster 3 gleiche in einer horizontalen, vertikalen oder diagonalen Reihe zu haben.
Die Substantive sind die zu implementierenden Klassen:
  1. Spielfeld
  2. Spiel Tic-Tac-Toe
  3. Feld
  4. Spieler
  5. Zug
  6. Spielstein
  7. Reihe

Das ist zumindest schon mal ein Anfang. Vielleicht brauchen wir nicht alle Klassen, vielleicht aber auch ganz andere, die sich aus der Spielbeschreibung so nicht ablesen lassen.

Die Klasse Spieler

Bisher hatten wir einen Spieler als Liste mit zwei Elementen verwaltet. Das erste Element war ein Symbol zur Unterscheidung der Spieler, das zweite Element war das Zeichen für die Ausgabe der von diesem Spieler besetzten Felder auf dem Spielfeld.


  spieler = [[:o, 'O'], [:x, 'X']]

Ein Spieler hat somit mindestens ein Attribut für den Namen zur Unterscheidung. Die Klasse Spieler könnte daher am einfachsten so aussehen:


class Spieler
  attr_accessor :name

  def to_s
    @name.to_s.upcase unless @name.nil?
  end
end

hätte so aber den Nachteil, dass man nachträglich den Namen verändern könnte. Das wollen wir verhindern und schränken den Zugriff auf das Attribut name ein, damit es nur beim Anlegen eines Objektes festgelegt und danach nur noch gelesen werden kann.


class Spieler
  attr_reader :name

  def initialize(name)
    @name = name
  end

  def to_s
    @name.to_s.upcase unless @name.nil?
  end
end

Wie testen die Klasse auch gleich mit folgendem Programmschnipsel:


s1 = Spieler.new(:x)
s2 = Spieler.new(:o)
s3 = Spieler.new("x")
s4 = Spieler.new("O")

puts s1, s2, s3, s4

C:\entwicklung>ruby lektion_17.rb
X
O
X
O

Prima! Weiter mit der nächsten Klasse.

Die Klasse Zug

Einen Zug im Spiel Tic-Tac-Toe hatten wir bisher als Liste mit den drei Elementen Spieler, Spalte und Zeile dargestellt. Der Zug Spieler O setzt in die Mitte sah zum Beispiel so aus:


  [:o, 2, 2]

Die Klasse für den objektorientierte Zug braucht somit mindestens ein Attribut für den Spieler (der den Zug macht). Dann braucht es noch Attribute, die die Position des Zuges auf dem Spielfeld festhalten. Die Position ist entweder durch die Angabe von Spalte und Zeile des Feldes eindeutig festgelegt, oder durch die Nummer des Feldes. Was nehmen wir?

Erinnern wir uns kurz daran, warum wir im bisherigen Code immer beides verwendet haben. Die Feldnummer erleichtert die Eingabe für den Spieler, der vor dem Computer sitzt. Er braucht nur eine Zahl von 1 bis 9 einzugeben.

Die Ausgabe des Spielfeldes erfordert jedoch die Darstellung als Spalte und Zeile, da sie ja zeilenweise erfolgt. Daher mussten wir aus der eingegebenen Feldnummer zunächst Spalte und Zeile berechnen. Das Umwandeln der Feldnummer in Spalte und Zeile und zurück übernahmen die beiden Methoden nummer_aus_spalte_zeile (wird 5 mal verwendet) und nummer_in_spalte_zeile (wird 9 mal verwendet).

Die Klasse Zug muss also folgendes leisten:
  1. Objekte lassen sich mit Angabe von Spieler und Feldnummer erzeugen
  2. Auf Wunsch kann ein Objekt aber auch die Spalte und Zeile liefern
  3. Das Umwandeln von Feldnummer in Spalte und Zeile und umbekehrt erledigt sie selbst; das bleibt für die Außenwelt unsichtbar.

Hier ist die Klasse Zug:


class Zug
  attr_reader :spieler, :nummer, :spalte, :zeile

  def initialize(spieler, nummer)
    @spieler = spieler
    @nummer  = nummer
    @spalte, @zeile = nummer_in_spalte_zeile(nummer)
  end

  def to_s
    "Zug[spieler=#{@spieler},nummer=#{@nummer},spalte=#{@spalte},zeile=#{@zeile}]" 
  end

  private

  def nummer_in_spalte_zeile(num)
    spalte = ((num-1) % 3)
    zeile = (((num + 2 ) / 3 ) - 1)
    [spalte+1, zeile+1]
  end

  def nummer_aus_spalte_zeile(spalte, zeile)
    nummer = 0
    nummer = (spalte-1)*1 + (zeile-1)*3 + 1 unless (spalte.nil? or zeile.nil?)
    nummer
  end
end

Zwei Dinge sind neu, die wir hier kurz besprechen. Als erstes fällt das Schlüsselwort private auf. Das bedeutet, dass alle nachfolgenden Methoden der Klasse für die Außenwelt unsichtbar sind, sie können also nicht von außen aufgerufen werden. Nur innerhalb der Klasse sind sie verfügbar.

Zum Testen wieder ein kleines Beispiel:


s = Spieler.new(:o)
z = Zug.new(s, 7)

puts z
print "Nummer ist: ", z.nummer, "\n" 
print "Spalte ist: ", z.spalte, "\n" 
print "Zeile  ist: ", z.zeile, "\n" 

Das liefert:


C:\entwicklung>ruby lektion_17.rb
Zug[spieler=O,nummer=7,spalte=1,zeile=3]
Nummer ist: 7
Spalte ist: 1
Zeile  ist: 3

Die Klasse Spielfeld

Die Klasse Spielfeld kümmert sich um die Verwaltung der Züge und die Ausgabe des Spielfeldes. Beginnend mit der alten Methode spielfeld, die wir in print umbenennen, bauen wir nacheinander die anderen alten Methoden print_zeile und print_feld ein. Aber schauen wir uns die Klasse zunächst an.


class Spielfeld
  attr_accessor :feldnummerierung
  @@reihen = [
    [1, 2, 3],
    [4, 5, 6],
    [7, 8, 9],

    [1, 4, 7],
    [2, 5, 8],
    [3, 6, 9],

    [1, 5, 9],
    [3, 5, 7],
  ]

  def initialize(feldnummerierung = false)
    @zuege = []
    @spieler_x = Spieler.new(:x)
    @spieler_o = Spieler.new(:o)
    @feldnummerierung = feldnummerierung
  end

  def spieler
    [@spieler_o, @spieler_x]
  end

  def zuege
    @zuege
  end

  def zug_hinzu(zug_neu)
    # Nicht erlauben, wenn das Feld schon besetzt ist
    erlaubt = true
    @zuege.each do |zug|
      if zug.nummer == zug_neu.nummer
        # Einen Zug für diese Feld gibt es schon
        erlaubt = false
        break
      end
    end
    @zuege << Zug.new(zug_neu.spieler, zug_neu.nummer) if erlaubt
    erlaubt
  end

  # Methode, die das Spielfeld im Ausgabebereich 'aus' ausgibt.
  # Ist für ein Feld noch kein Zug erfolgt, dann wird die
  # Nummer des Feldes ausgegeben. Die Felder sind dabei von
  # links nach rechts und oben nach unten von 1 bis 9 fortlaufend
  # nummeriert.
  def print(aus)
    aus.puts  "/-----------\\" 
    aus.print "| " 

    print_zeile(aus, 1)

    aus.puts " |" 
    aus.puts  "|---|---|---|" 
    aus.print "| " 

    print_zeile(aus, 2)

    aus.puts " |" 
    aus.puts  "|---|---|---|" 
    aus.print "| " 

    print_zeile(aus, 3)

    aus.puts " |" 
    aus.puts "\\-----------/" 
  end

  # Bestimmt den Status einer Reihe in der aktuellen Spielsituation. 
  # Rückgabewerte sind eine Liste der besetzten und der freien Felder.
  # Die Liste der besetzten Felder ist aufgeteilt nach Spielern und
  # in einem Hash nach folgender Form organisiert:
  #
  #  besetzt = {
  #    :o => Liste der von O besetzten Felder, 
  #    :x => Liste der von X besetzten Felder
  #  }
  #   
  def reihen_status(reihe)
    # Welche Felder sind noch frei?
    frei_alle = freie_felder
    frei = []
    for feld in reihe
      if frei_alle.include?(feld)
        frei << feld
      end
    end

    # Welche Felder sind vom wem besetzt? Da ist etwas mehr zu tun.
    besetzt = {}
    for s in [@spieler_o, @spieler_x]
      besetzt[s] = []
    end
    for zug in @zuege
      # Liegt der zug in der fraglichen Reihe?
      feld = zug.nummer
      if reihe.include?(feld)
        besetzt[zug.spieler] << feld
      end
    end
    [besetzt, frei]
  end

  def freie_felder
    frei = [1, 2, 3, 4, 5, 6, 7, 8, 9]
    for zug in @zuege
      frei.delete(zug.nummer)
    end
    frei
  end

  # Schaut nach, ob alle Züge gemacht sind
  def felder_frei?
    @zuege.nil? ? true : @zuege.size < 9
  end

  def Spielfeld.reihen
    @@reihen
  end

  private

  # Methode zum Ausgeben einer einzigen Zeile im Ausgabebereich 'aus'.
  # Welche Zeile ausgegeben werden soll ist in 'zeile' übergeben.
  def print_zeile(aus, zeile)
    spalte = 1
    1.upto(3) do 
      print_feld(aus, spalte, zeile)
      aus.print " | " unless spalte == 3
      spalte += 1
    end
  end

  # Methode, die ein bestimmtes Feld ausgibt. Entweder wird
  # das Symbol für den Spieler ausgegeben, der das Feld besetzt hat,
  # oder es wird die laufende Nummer des Feldes ausgegeben, sofern
  # die Feldnummerierung angeschaltet ist.
  def print_feld(aus, spalte, zeile)
    res = " " 
    res = ((spalte-1)*1 + (zeile-1)*3 + 1) if @feldnummerierung
    # Den Zug suchen, der an dieser Stelle auszugeben ist.
    for zug in @zuege do
      if zug.spalte == spalte and zug.zeile == zeile
        res = zug.spieler
        break
      end
    end
    aus.print res
  end

end
Direkt nach der Definition der Klasse Spielfeld folgt eine Variable reihen, die eine Liste enthält, die wiederum aus acht kleinen Listen mit den drei Feldnummern der jeweiligen Reihe besteht. Das doppelte @-Zeichen am Beginn des Variablennamens @@reihen markiert diese Variable als Klassenvariable. Das bedeutet, dass es sie für alle Objekte die im Programmverlauf von dieser Klasse erzeugt werden nur ein einziges mal gibt. Es bekommt also nicht jedes Objekt der Klasse seine eigene Variable reihen. Das macht Sinn, denn jedes Spielfeld in Tic-Tac-Toe hat diese Reihen.

Im Konstruktor erzeugt die Klasse zunächst eine leere Liste für die Züge und legt die zwei Spieler X und O an.

Die Methode spieler liefert das Spielerpaar in einer kleinen Liste zurück.

Die Methode zuege, schon klar, sie gibt Zugriff auf die Liste mit den Zügen. Sie würde allerdings sicher bei der Kapselungskontrolle durchfallen. Aus Bequemlichkeit belassen wir es einmal dabei, behalten aber im Hinterkopf, dass hier jemand von außen die Züge manipulieren und so einen ungültigen Zug unterschieben könnte.

Die Methode zug_hinzu ist bekannt. Vergleiche sie mit der alten Methode! Es gibt nur noch einen Übergabeparameter: den neuen Zug als Objekt der Klasse Züge. Der Spieler, der diesen Zug ausführt ist im Objekt des Zuges selbst versteckt und die Spalte und Zeile und die gesamte Zugliste brauchen wir auch nicht mehr mit uns herumzuschleppen.

Die nächste Methode print gibt das Spielfeld aus.

Die Methode reihen_status liefert alle notwendige Zustandsinformation über eine Reihe. Beim Vergleich mit dem alten Code stellt sich heraus, dass wir damals diesen Reihenstatus vornehmlich für den intelligenten Computerzug verwendeten. Beim Umgestalten des Codes wird aber deutlich, dass wir bei der Bestimmung des Gewinners im Grunde auch einen Status über die Reihen erstellen. Somit können wir den Reihenstatus nun an mindestens zwei Stellen gut gebrauchen und die Bestimmung des Gewinners (kommt noch weiter unten) wird dadurch um ein paar Zeilen kürzer.

Wir haben hier während der Umbauarbeiten somit zwei Stellen im alten Code entdeckt, die dasselbe auf ziemlich ähnliche Weisen tun. Das ist der Smell Doppelter Code und das Umbauen des Codes nennt man Refactoring.

Weitere Methoden folgen, die auf die übliche Weise aus alten Methoden umgebaut oder neu eingefügt wurden. Besonders möchte ich hier auf die Methode Spielfeld.reihen verweisen. Das vorgefügte Spielfeld. vor den eigentlichen Methodennamen kennzeichnet eine sogenannte Klassenmethode. Genauso wie die Klassenvariable, die wir oben schon kennen gelernt haben, gibt es diese Methode für alle Objekte der Klasse nur einmal. Die Methode macht nichts weiter als nach außen Zugriff auf die Liste mit den Reihen zu gewähren.

Durch unser Refactoring haben wir nun alle Methoden, die irgendwie etwas mit dem Spielfeld zu tun haben in der Klasse Spielfeld zusammen gruppiert, wir haben doppelten Code zusammengefasst und – oops sogar einen Fehler, der bisher unbemerkt bliebt, gefunden. Schau dir den alten Code nochmal an. Die Methode spielfeld wird mit dem Ausgabebereich aufgerufen (Parameter out), den sie bei jeder Zeilenausgabe an die Methode print_zeile weiterreicht. Bei der Zeilenausgabe ruft print_zeile für jedes Feld die Methode print_feld auf und vergisst dabei aber den Ausgabebereich out weiterzureichen. Gute Gelegenheit das zu korrigieren.

Die Klasse TicTacToe verwaltet das Spielfeld und die Spielstrategie

Die Klasse TicTacToe verwaltet das Spielfeld und kümmert sich darum, vom menschlichen Spieler die Eingaben zu erfragen bzw. die Züge des Computergegners zu berechnen. Schauen wir uns die Klasse wieder zunächst an.


class TicTacToe
  attr_accessor :strategie

  def initialize(strategie = nil)
    @spielfeld = Spielfeld.new
    @strategie = strategie.nil? ? LeichteSpielStrategie.new : strategie
  end

  def feldnummerierung_ein
    @spielfeld.feldnummerierung = true
  end

  def feldnummerierung_aus
    @spielfeld.feldnummerierung = false
  end

  def play(aus, ein)
    @spielfeld.print(aus)
    gewinner = the_winner_is
    beendet  = !@spielfeld.felder_frei? or (gewinner != nil)
    spieler = @spielfeld.spieler

    wer = nil
    aus.print "Was spielst du, X oder O? " 
    eingabe = ein.gets.downcase.chomp
    wer = (eingabe == 'x') ? 1 : 0

    aus.print "Los geht's! Du spielst #{spieler[wer]}, und ich #{spieler[1^wer]}!" 
    aus.puts " Du faengst an." 

    while true
      # Der menschliche Spieler zuerst
      zug_okay = false
      until zug_okay
        aus.print "#{spieler[wer]} ist am Zug: " 
        nummer = ein.gets.to_i
        break if nummer == 0
        zug_okay = @spielfeld.zug_hinzu(Zug.new(spieler[wer], nummer))
      end
      @spielfeld.print(aus)
      gewinner = the_winner_is
      beendet  = ((!@spielfeld.felder_frei?) or (gewinner != nil))
      break if (beendet or !zug_okay)
      wer += 1
      wer %= 2

      # Gleich den Zug des Computers anschließen
      zug_okay = false
      until zug_okay
        aus.print "\nJetzt bin ich dran! " 
        zug = computer_zug(spieler[wer])
        break if zug.nil?
        aus.puts "Ich setze auf das Feld #{zug.nummer}." 
        zug_okay = @spielfeld.zug_hinzu(zug)
      end
      @spielfeld.print(aus)
      gewinner = the_winner_is
      beendet  = ((!@spielfeld.felder_frei?) or (gewinner != nil))

      break if (beendet or !zug_okay)
      wer += 1
      wer %= 2
    end
    # Rückgabewerte: der aktuelle Spieler, falls er der Gewinner ist.
    if gewinner != nil
      return spieler[wer]
    else
      return nil
    end

  end

  # Lässt 2 Spieler miteinander spielen
  def play_2_spieler(aus, ein)
    @spielfeld.print(aus)

    gewinner = the_winner_is
    spieler = @spielfeld.spieler
    gewinner = nil
    wer = 0
    while true
      zug_okay = false
      until zug_okay
        aus.print "#{spieler[wer]} ist am Zug: " 
        nummer = ein.gets.to_i
        break if nummer == 0
        zug_okay = @spielfeld.zug_hinzu(Zug.new(spieler[wer], nummer))
      end
      @spielfeld.print(aus)
      gewinner = the_winner_is
      beendet  = ((!@spielfeld.felder_frei?) or (gewinner != nil))
      break if (beendet or !zug_okay)
      wer += 1
      wer %= 2
    end
    # Rückgabewerte: der aktuelle Spieler, falls er der Gewinner ist.
    if gewinner != nil
      return spieler[wer]
    else
      return nil
    end
  end

  def computer_zug(aktueller_spieler)
    @strategie.next(@spielfeld, aktueller_spieler)
  end

  private

  # Stellt fest, ob es einen Gewinner gibt
  def the_winner_is
    # Variable für den Gewinner
    the_winner = nil

    # Für alle Reihen testen
    for reihe in Spielfeld::reihen
      besetzt, frei = @spielfeld.reihen_status(reihe)

      # In der Reihe nur ein Gewinn, wenn kein Feld mehr frei
      if frei.size == 0
        for spieler in @spielfeld.spieler
          # spieler hat in dieser Reihe gewonnen, wenn er alle
          # 3 möglichen Felder besetzt hat
          the_winner = spieler if besetzt[spieler].size == 3
          break if the_winner != nil # Gewinner gefunden, for-spieler-Schleife verlassen
        end
      end

      # Wenn es einen Gewinner gibt, ist es nicht mehr notwendig,
      # in den anderen Reihen nachzuschauen.
      break if the_winner != nil # for-reihe-Schleife verlassen
    end

    the_winner
  end
end

Den Konstruktor (Methode initialize) erzeugt zunächst ein leeres Spielfeld und legt die Spielstrategie fest. Sofern beim Erzeugen des Spiels keine Spielstrategie über den Parameter gewünscht wurde, verwendet die Klasse selbständig die leichte Spielstrategie. Die Klasse Strategie, die wir weiter unten besprechen, fasst die Methoden zusammen, die mit der Bestimmung von automatischen Zügen des Computergegners zu tun haben.

Das Spiel startet man von außen über eine der beiden Methoden play oder play_2_spieler. Die erste Methode play lässt das Spiel im Computermodus ablaufen, wobei ein menschlicher Spieler gegen den Computergegner spielen kann. Die Methode play_2_spieler führt zwei menschliche Spieler durch ein gemeinsames Spiel.

Auch in diesen Methoden haben wir einiges umzustellen gehabt – vergleiche selbst mit dem alten Code.

Schließlich bestimmt die private Methode the_winner_is den Gewinner.

Spielstrategien

Wir haben einiges geleistet und sind auch fast fertig. Schauen wir uns nur noch die Klassen der Spielstrategien an. Es gibt drei davon: SpielStrategie, LeichteSpielStrategie und SchwereSpielStrategie. Sie umfassen alle unsere bisherigen Methoden für die Bestimmung des nächsten Computerzuges. Die Methode intelligenter_zug habe ich dabei noch etwas anpassen müssen, weil Livia es doch tatsächlich schaffte, den Computer mit einer X0X Situation an der Diagonale zu schlagen!

Ansonsten hat sich inhaltlich an den Methoden nicht viel geändert, sie sind nur über drei Klassen verteilt.


class SpielStrategie
  def next(spielfeld, aktueller_spieler)
    zufalls_zug(spielfeld, aktueller_spieler)
  end

  private

  def zufalls_zug(spielfeld, aktueller_spieler)
    ...
  end
end

class LeichteSpielStrategie < SpielStrategie
  def next(spielfeld, aktueller_spieler)
    naiver_zug(spielfeld, aktueller_spieler)
  end

  def naiver_zug(spielfeld, aktueller_spieler)
    ...
  end
end

class SchwereSpielStrategie < LeichteSpielStrategie
  def next(spielfeld, aktueller_spieler)
    intelligenter_zug(spielfeld, aktueller_spieler)
  end

  def intelligenter_zug(spielfeld, aktueller_spieler)
    ...
  end
end

Alle drei Klassen haben eine Methode gemeinsam, next(spielfeld, aktueller_spieler). Diese Methode wird von außen aufgerufen, wenn man einen neuen Zug entsprechend der jeweiligen Strategie benötigt. Jede der Strategien macht aber etwas anderes, um diesen nächsten Zug zu berechnen. Die reine SpielStrategie macht zufällig einen Zug, die LeichteSpielStrategie nimmt das nächste frei Feld für den Zug und die SchwereSpielStrategie berechnet einen möglichst schlauen Zug.

Dir fällt sicher etwas Neues auf. Bei der Definition der Klasse LeichteSpielStrategie schreiben wir nach dem Klassennamen eine spitze öffnende Klammer (ein Kleiner-als Zeichen) und danach den Namen einer anderen Klasse SpielStrategie.


class LeichteSpielStrategie < SpielStrategie
  ...
end

Das Kleiner-als Zeichen bedeutet Vererbung. Die Klasse LeichteSpielStrategie erbt alles was die Klasse SpielStrategie zu vererben hat und zwar sind das alle öffentlichen Methoden und Variablen. Man sagt in dieser Vererbungsbeziehung zu der erbenden Klasse Subklasse (sub im Sinne von untergeordnet) und zu der Klasse, die etwas zum Vererben anbietet Superklasse (super im Sinne von übergeordnet).

Und die Klasse SchwereSpielStrategie erbt weiter von LeichteSpielStrategie. Somit bekommt SchwereSpielStrategie auf alle öffentlichen Methoden und Variablen ihrer beiden Superklassen Zugriff.

Vererbung ist ein Mittel um doppelten Code zu vermeiden. Die SchwereSpielStrategie kann die Methode zufalls_zug verwenden, obwohl diese gar nicht innerhalb der Klasse definiert ist. Die Methode stammt aus der Superklasse SpielStrategie von der die SchwereSpielStrategie sie indirekt über die Klasse LeichteSpielStrategie geerbt hat. Klingt kompliziert? Lies dir den Artikel über Vererbung bei Wikipedia durch und nimm auch ein gutes Ruby-Buch zur Hand. Zur Zeit ist eines der besten Bücher The Ruby Way, zwar in Englisch, aber du findest sicher auch ein gutes auf Deutsch.

Fertig!

Jetzt packst du alle Klassen hintereinander weg in eine Datei mit Namen tictactoe.rb und legst noch eine weitere Datei mit Namen lektion_17.rb mit folgendem Inhalt an:


require "tictactoe" 

t = TicTacToe.new
t.strategie = SchwereSpielStrategie.new
t.feldnummerierung_aus
gewinner = t.play(STDOUT, STDIN)
#gewinner = t.play_2_spieler(STDOUT, STDIN)

# Gibt es einen Gewinner?
if gewinner == nil
  puts "Das Spiel endet UNENTSCHIEDEN!" 
else
  puts "Der Gewinner ist #{gewinner}!" 
end

Und schon kannst du Tic-Tac-Toe wie üblich spielen!


[08.02.2008, 23:15]:> ruby lektion_17.rb
/-----------\
|   |   |   |
|---|---|---|
|   |   |   |
|---|---|---|
|   |   |   |
\-----------/
Was spielst du, X oder O? x
Los geht's! Du spielst X, und ich O! Du faengst an.
X ist am Zug: 1
/-----------\
| X |   |   |
|---|---|---|
|   |   |   |
|---|---|---|
|   |   |   |
\-----------/

Jetzt bin ich dran! Ich setze auf das Feld 5.
/-----------\
| X |   |   |
|---|---|---|
|   | O |   |
|---|---|---|
|   |   |   |
\-----------/
X ist am Zug: 
...

Alle vergangenen Lektionen rund um das Spiel Tic-Tac-Toe hier im Überblick. Den vollständigen Sourcecode findest du im Download.

Lektion 14 - Tic-Tac-Toe gegen den Computer

Erstellt von Frithjof Tue, 16 Oct 2007 20:24:00 GMT

Dein Computer gehorcht dir bisher prima bei der Ausführung des Rubyprogramms Tic-Tac-Toe. Er fühlt sich aber etwas einsam, weil er nicht wirklich mitspielen darf. Das wollen wir in dieser Lektion ändern – der Computer darf Tic-Tac-Toe spielen! Er wird in dieser Lektion aber noch kein Profi werden. Wir sind zufrieden, wenn er überhaupt mitspielt, auch wenn man ihn noch sehr gut besiegen kann.

Schauen wir uns an, wo wir den Computer als Gegner mit einbeziehen können. Die Methode play_2_spieler sorgt bisher dafür, dass zwei Spieler miteinander spielen können. Der Methode ist aber eigentlich ziemlich egal, ob die Eingaben für die Züge von einem menschlichen Spieler kommen, oder von einem Computerprogramm bestimmt werden. Du definierst dir also eine weitere Methode play_gegen_computer und änderst sie in geeigneter Weise ab.


# Lässt 1 Spieler gegen den Computer spielen
def play_gegen_computer(out, ein, zuege)
  spieler = [[:o, 'O'], [:x, 'X']]
  ergebnis = [false, nil]

end

Die Methode play_gegen_computer erhält beim Aufruf neben der Aus- und Eingabe die Liste der (noch leeren) Züge. Sie definiert dann die beiden Spieler in der Variablen spieler und das Ergebnis in der Variablen ergebnis wie bisher auch.

Es fehlt noch die Variable wer, die den aktuellen Spieler festlegt bzw. am Anfang den Spieler bestimmt, der mit dem Spiel beginnen darf. Beim Spiel gegen den Computer müssten wir festlegen, mit welchen Steinen (X oder O) der Computer spielen soll. Du fügst dafür eine Abfrage an den menschlichen Spieler ein und fragst ihn, mit welchem Stein er spielen möchte. Den anderen nimmt dann zwangsläufig der Computer. Wir legen außerdem fest, dass der menschliche Spieler immer anfangen darf.


# Lässt 1 Spieler gegen den Computer spielen
def play_gegen_computer(out, ein, zuege)
  spieler = [[:o, 'O'], [:x, 'X']]
  ergebnis = [false, nil]

  wer = nil
  out.print "Was spielst du, X oder O? " 
  eingabe = ein.gets.downcase.chomp
  wer = (eingabe == 'x') ? 1 : 0

  out.puts "Los geht's! Du spielst #{spieler[wer][1]}, und ich #{spieler[1^wer][1]}!" 
  out.puts "Du fängst an." 
end
Das Abfragen nach dem Spielstein erfolgt also in der Zeile:

  ...
  eingabe = ein.gets.downcase.chomp
  ...

ein ist die Eingabekonsole, auf der der menschliche Spieler etwas eintippt, gets holt sich die eingetippte Zeichenkette, downcase verwandelt die Eingabe in Kleinbuchstaben und chomp schneidet das unsichtbare Zeichen für die Entertaste ab.

Bis jetzt weißt du im Rubyprogramm aber immer noch nicht, ob der Spieler nun X (oder x) oder O (oder o) eingegeben hat. Das findest du erst mit der nächsten Zeile heraus:


  ...
  eingabe = ein.gets.downcase.chomp
  wer = (eingabe == 'x') ? 1 : 0
  ...

Mit dem ternären Operator entscheidest du, mit welchem Stein der menschliche Spieler nun tatsächlich anfängt. Hat er X (oder x) eingetippt, dann erhält er den Stein X, in allen anderen Fällen, also egal ob er O (oder o) oder irgendeine andere Zeichenkette eingetippt hat, erhält er den Stein O.

Na prima, jetzt wissen wir, mit welchem Stein der menschliche Spieler beginnt und können nun in die Spielschleife eintreten. Dort darf der menschliche Spieler zuerst seinen Zug eingeben, der Zug wird gemacht und das neue Spielfeld ausgeben. Danach macht sofort der Computer weiter mit seinem Zug. Und hier wird es dann spannend. Schauen wir uns also die fertige Methode play_gegen_computer an:


# Lässt 1 Spieler gegen den Computer spielen
def play_gegen_computer(out, ein, zuege)
  spieler = [[:o, 'O'], [:x, 'X']]
  ergebnis = [false, nil]

  wer = nil
  out.print "Was spielst du, X oder O? " 
  eingabe = ein.gets.downcase.chomp
  wer = (eingabe == 'x') ? 1 : 0

  out.puts "Los geht's! Du spielst #{spieler[wer][1]}, und ich #{spieler[1^wer][1]}!" 
  out.puts "Du fängst an." 

  while true
    # Der menschliche Spieler zuerst
    zug_okay = false
    until zug_okay
      out.print "#{spieler[wer][1]} ist am Zug: " 
      nummer = ein.gets.to_i
      break if nummer == 0
      spalte, zeile = nummer_in_spalte_zeile(nummer)
      zug_okay = zug_hinzu(spieler[wer][0], spalte, zeile, zuege)
    end
    spielfeld(out, zuege)
    ergebnis = ist_beendet?(zuege)
    beendet = ergebnis[0]
    break if (beendet or !zug_okay)
    wer += 1
    wer %= 2

    # Gleich den Zug des Computers anschließen
    zug_okay = false
    until zug_okay
      out.puts "\nJetzt bin ich dran!" 
      zug = computer_zug(zuege, wer)
      out.puts "Ich setze auf das Feld #{nummer_aus_spalte_zeile(zug[1], zug[2])}." 
      break if zug.nil?
      zug_okay = zug_hinzu(spieler[wer][0], zug[1], zug[2], zuege)
    end
    spielfeld(out, zuege)
    ergebnis = ist_beendet?(zuege)
    beendet = ergebnis[0]
    break if (beendet or !zug_okay)
    wer += 1
    wer %= 2
  end
  # Rückgabewerte: der aktuelle Spieler, falls er der Gewinner ist.
  gewinner = ergebnis[1]
  if gewinner != nil
    return spieler[wer]
  else
    return nil
  end
end

Der Rubycode für das Durchführen des Computerzuges ähnelt dem für den menschlichen Spieler ziemlich. Aber es gibt doch einige Unterschiede.

Die entscheidende Stelle ist


  ...
  zug = computer_zug(zuege, wer)
  ...

Hier wird eine Methode computer_zug aufgerufen, die die aktuelle Liste der Züge und den aktuellen Spielstein des Computers erhält. In dieser Methode kannst du dann eine beliebige Spielstrategie des Computers entwickeln. Ich zeige dir gleich zwei mögliche einfache Strategien, aber zunächst nehmen wir mal an, die Methode liefert uns einen Zug in der Form einer kleinen Liste


  [wer, spalte, zeile]

Der Computerzug liefert uns also nicht die Feldnummer, sondern gleich die Spalte und Zeile für die Platzierung des Spielsteins. Die Feldnummer ist ja vornehmlich zur erleichterten Eingabe für den menschlichen Spieler gedacht gewesen.

Nachdem der Computerzug erfolgreich hinzugefügt, das Spielfeld erneut ausgegeben und der nächste Spieler bestimmt wurde, ist der menschliche Spieler wieder am Zug, sofern das Spiel noch nicht vorbei ist (keine freien Felder mehr, oder jemand hat gewonnen).

Spielstrategien für den Computer

Schauen wir uns also nun wie besprochen ein paar Spielstrategien für den Computer an.


def computer_zug(zuege, wer)
  naiver_zug(zuege, wer)
end

def naiver_zug(zuege, wer)
  frei = freie_felder(zuege)
  zug = nil
  if frei.size > 0
    # Nehme das erste freie Feld
    spalte, zeile = nummer_in_spalte_zeile(frei[0])
    zug = [wer, spalte, zeile]
  end
  zug
end

def freie_felder(zuege)
  frei = [1, 2, 3, 4, 5, 6, 7, 8, 9]
  for zug in zuege
    nummer = nummer_aus_spalte_zeile(zug[1], zug[2])
    frei.delete(nummer)
  end
  frei
end

Der Naive Zug

Die Methode computer_zug ruft die Methode naiver_zug auf, die sich ziemlich dumm anstellt. Sie nimmt nämlich einfach das nächste freie Feld. Dabei geht sie wie folgt vor.

Sie bestimmt zuerst die freien Felder mit Hilfe der Methode freie_felder.

  ...
  frei = freie_felder(zuege)
  ...
Wenn es mindestens ein freies Feld gibt, beschafft sie sich die Feldnummer des ersten Feldes, daraus die Spalte und Zeile und bildet damit die kleine Liste, die den Zug beschreibt.

  ...
  if frei.size > 0
    # Nehme das erste freie Feld
    spalte, zeile = nummer_in_spalte_zeile(frei[0])
    zug = [wer, spalte, zeile]
  end
  ...

Was meinst du, wie oft der Computer mit dieser Strategie gewinnt? Ich denke kaum, der menschliche Spieler braucht sich nicht sehr anzustengen, um zu gewinnen. Außerdem würde der menschliche Spieler nach ein paar Runden bemerken, dass der Computer immer auf das erst beste freie Feld setzt.

Wir brauchen eine andere Strategie.

Der Zufallszug

Damit der menschliche Spieler nicht so leicht die Spielstrategie des Computers durchschaut, müssen wir ein wenig Zufall mit ins Spiel bringen. Das ist zwar auch noch keine wirklich gute Strategie für den Computer, um zu gewinnen, aber immerhin, tut er wenigstens so.

Du kommentierst also zunächst den Aufruf der naiven Methode aus und rufst die neue Methode zufalls_zug auf.

Moment mal! Aber wie soll der Computer eine zufällige Entscheidung treffen? Soll er seine Augen schließen und mit dem Finger auf irgendein (freies) Feld zeigen? Wie geht das? Du hast bisher keinen Rubybefehl für den Zufall kennen gelernt. Im richtigen Leben gibt es viele Möglichkeiten, den Zufall entscheiden zu lassen: man wirft eine Münze, wer vorher auf Kopf gesetzt hat, gewinnt genau dann, wenn die Münze mit der Kopfseite oben zu liegen kommt. Oder du hast Stöckchen, von denen eines besonders kurz ist. Wer dieses zieht, gilt als der vom Zufall auserwählte.

Aber das hilft uns alles nicht weiter. Das Problem ist die Entscheidung für einen von mehreren möglichen Werten. Angenommen wir haben eine Liste der freien Felder vom aktuellen Spielstand. Der Computer müsste sich für einen Index in der Liste entscheiden. Beim naiven Zug nahm er immer den ersten Index 0. Wie bringen wir ihn dazu, mal den Index 0, mal den Index 3 oder 9 zu nehmen?

Ich zeige dir im folgenden meine Idee. Du kannst dir aber gerne auch eine andere Lösung überlegen. Der Zufallszug hier funktioniert so:


def computer_zug(zuege, wer)
  #naiver_zug(zuege, wer)
  zufalls_zug(zuege, wer)
end

def zufalls_zug(zuege, wer)
  frei = freie_felder(zuege)
  zug = [wer, 0, 0]
  if frei.size > 0
    jetzt = Time.now
    sekunden = jetzt.sec
    index = sekunden % frei.size
    spalte, zeile = nummer_in_spalte_zeile(frei[index])
    zug = [wer, spalte, zeile]
  end
  zug
end
  1. Der Computer (das heißt das Rubyprogramm) bestimmt zuerst wieder die freien Felder.
  2. Dann schaut es nach der aktuellen Uhrzeit! Heh? Was hat die Uhrzeit mit dem Zufall zu tun? Die Idee ist, bei der Uhrzeit nur auf die Sekunden zu achten. Wenn du zu einem beliebigen Zeitpunkt auf deine Uhr schaust, ist es sehr unwahrscheinlich, dass du zweimal dieselbe Sekundenanzeige siehst. Erst wenn du genau nach einer Minute wieder auf die Uhr schaust, wird die selbe Sekunde angezeigt.
  3. Der Computer bestimmt also aus der aktuellen Uhrzeit nur die Sekunden.
  4. Dann teilt der die Sekunden durch die Anzahl der freien Felder und merkt sich von dieser Division nur den Rest. Der Rest ist immer kleiner als das wodurch man dividiert (Divisor).
  5. Somit kann der Computer diesen Rest als Index für die Liste der freien Felder ansehen.

Der Computer hat nun eine sehr einfache Möglichkeit, eine Zufallsentscheidung zwischen mehreren Möglichkeiten zu treffen. Es reicht allein die Uhrzeit.

Probiere die beiden Strategien, die naive und die mit Zufall, ein wenig aus, indem du abwechselnd eine Runde spielst und dabei jeweils die entsprechende Zeile in der Methode computer_zug aus- bzw. einkommentierst.

Bei dem Zufallszug hast du als menschlicher Spieler das Gefühl, der Computer denkt sich etwas bei seinen Zügen, weil du kein Muster erkennst. In Wahrheit denkt er sich natürlich überhaupt nichts. Es ist immer noch keine gute Strategie für ihn, um oft zu gewinnen.

Du kannst ja gerne eigene Strategien entwickeln. In der nächsten Lektion schauen wir uns gemeinsam eine bessere an. Aber für heute ist es denke ich genug.

Peter und Livia

Peter: Mir ist in der Methode play_gegen_computer folgende Zeile aufgefallen.

  ...
  out.puts "Los geht's! Du spielst #{spieler[wer][1]}, und ich #{spieler[1^wer][1]}!" 
  ...
Da verstehe ich nicht, was das mit dem kleinen Dach ^ zu bedeuten hat?

Livia: Das ist ein Bit-Operator. Er heißt XOR, was eXklusives OdeR bedeutet, also entweder oder. 1^wer wird also 1, nur dann, wenn wer gleich 0 ist. Es wird 0 genau dann und nur dann, wenn wer 1 ist. Somit kann man leicht zu einem gegebenen Spieler immer den anderen Spieler angeben.

Lektion 13 - Tic-Tac-Toe, Wer gewinnt?

Erstellt von Frithjof Sun, 30 Sep 2007 04:35:00 GMT

Weiter mit Tic-Tac-Toe. In dieser Lektion wirst du den Gewinner eines Spieles bestimmen. Das Spiel ist beendet, sobald einer der Spieler eine beliebige Reihe horizontal, vertikal oder diagonal mit seinen Steinen belegen konnte. Die Methode dafür könnte so arbeiten:
  • Sie bestimmt für alle möglichen Reihen (horizontal, vertikal, oder diagonal) die Anzahl der Steine des Spielers, der gerade den aktuellen Zug gemacht hat.
  • Sobald die Methode eine Reihe findet, in der der Spieler 3 Steine hat, hört sie auf zu suchen und gibt bekannt, dass dieser Spieler gewonnen hat.

Die Methode schreiben wir natürlich in die Datei tictactoe.rb, in der wir ja alle Methoden rund um das Spiel sammeln. Suche dort die bereits angelegte Methode ist_beendet?. Sie überprüft, ob das Spiel bereits zu Ende ist und sie musst du erweitern, denn das Spiel ist ebenfalls beendet, wenn einer der Spieler gewonnen hat. Den Test, ob und wer gewonnen hat machst du in einer separaten Methode:


# Berechnet die Feldnummer aus gegebener Spalte und Zeile
def nummer_aus_spalte_zeile(spalte, zeile)
  nummer = 0
  nummer = (spalte-1)*1 + (zeile-1)*3 + 1 (unless spalte.nil? or zeile.nil?)
  nummer
end

# Stellt fest, ob es einen Gewinner gibt
def the_winner_is(zuege)
end

# Schaut nach, ob das Spiel schon aus ist
def ist_beendet?(zuege)
  alle_zuege_gemacht = zuege.nil? ? false : zuege.size >= 9
  gewinner = the_winner_is(zuege)
  alle_zuege_gemacht or gewinner != nil
end

Du fügst in der Methode ist_beendet? eine Variable alle_zuege_gemacht ein. Zuerst testet die Methode, ob das Spiel beendet ist, weil alle Züge gemacht wurden. Dann bestimmt sie den Gewinner durch den Aufruf der Methode the_winner_is. Das Spiel ist nun beendet, wenn entweder alle möglichen Züge gemacht wurden, oder es einen Gewinner gibt.

Die Felder überprüfen wir am besten über die Feldnummer. Dafür brauchst du die Methode nummer_aus_spalte_zeile, die aus einer Spalte und Zeile die zugehörige Feldnummer zurückberechnet. Bei der Eingabe von Zügen machst du ja genau das Umgekehrte: die Berechnung von Spalte und Zeile aus der eingetippten Feldnummer.

Weiter mit der Methode the_winner_is. Es geht los mit den Reihen. Du legst für jede Reihe eine kleine Liste mit 3 Elementen für die 3 Felder der Reihe an. Alle Reihen packst du in eine große gemeinsame Liste. Du legst eine Variable für den möglichen Gewinner an und gibst ihn schon mal als Ergebnis zurück.


def the_winner_is(zuege)
  reihen = [
    [1, 2, 3],
    [4, 5, 6],
    [7, 8, 9],

    [1, 4, 7],
    [2, 5, 8],
    [3, 6, 9],

    [1, 5, 9],
    [3, 5, 7],
  ]

  # Variable für den Gewinner
  the_winner = nil

  the_winner
end

Naja, das ist noch nicht viel, aber immerhin würde das Programm so schon fehlerfrei durchlaufen. Es erkennt den Gewinner aber natürlich noch nicht. Also weiter geht’s.


def the_winner_is(zuege)
  reihen = [ [1, 2, 3], [4, 5, 6], [7, 8, 9], 
             [1, 4, 7], [2, 5, 8], [3, 6, 9], 
             [1, 5, 9], [3, 5, 7], ]

  # Variable für den Gewinner
  the_winner = nil

  # Für beide Spieler testen
  for spieler in [:o, :x]
    felder_besetzt = []

    for zug in zuege
      if zug[0] == spieler
        feld = nummer_aus_spalte_zeile(zug[1], zug[2])
        felder_besetzt << feld
      end
    end

  end

  the_winner
end

In einer Schleife betrachtest du beide Spieler nacheinander. Du legst für den Spieler eine Liste an, felder_besetzt. In der merkst du dir genau die Felder, die der aktuelle Spieler bereits besetzt hält.

Anschließend gehst du Reihe für Reihe durch und schaust, ob für eine Reihe die Liste felder_besetzt alle Felder der Reihe enthält. Das wäre dann die Reihe, mit der der Spieler gewonnen hat. Falls keine der Reihen alle ihre Felder in der Liste felder_besetzt wiederfindet, dann hat der Spieler noch nicht gewonnen.


def the_winner_is(zuege)
  reihen = [ [1, 2, 3], [4, 5, 6], [7, 8, 9], 
             [1, 4, 7], [2, 5, 8], [3, 6, 9], 
             [1, 5, 9], [3, 5, 7], ]

  # Variable für den Gewinner
  the_winner = nil

  # Für beide Spieler testen
  for spieler in [:o, :x]
    felder_besetzt = []

    for zug in zuege
      if zug[0] == spieler
        feld = nummer_aus_spalte_zeile(zug[1], zug[2])
        felder_besetzt << feld
      end
    end

    # In felder_besetzt stehen die Felder, die vom aktuellen
    # Spieler belegt sind. Die können wir nun für alle Reihen testen.
    for reihe in reihen
      gewonnen = true
      for feld in reihe
        # gewonnen wird falsch (false), wenn das aktuelle Feld
        # der Reihe nicht besetzt ist.
        gewonnen = (gewonnen and felder_besetzt.include?(feld))
        break if gewonnen == false # in der Reihe kein Gewinn mehr 
      end
      if gewonnen
        the_winner = spieler
        break # Gewinner gefunden, aufhören weiter zu suchen
      end
    end

    # Wenn es einen Gewinner gibt, für den nächsten gar nicht 
    # erst mehr versuchen, denn dieser kann nicht auch gleichzeitig 
    # gewonnen haben, das hätten wir beim vorherigen Zug bereits bemerkt.
    break if the_winner != nil

  end

  the_winner
end

Zwei Dinge sollten wir uns hier etwas genauer anschauen, die in der einen Zeile


  ...
  gewonnen = (gewonnen and felder_besetzt.include?(feld))
  ...

passieren. Die Frage-Nachricht include? wird hier an die Liste felder_besetzt geschickt. Include? bedeutet soviel wie Enthältst du das hier?. Was die Liste felder_besetzt enthalten soll, geben wir der Nachricht in der Variable feld mit. Die Frage-Nachricht liefert uns ein true oder false zurück, je nachdem ob die Liste das angefragte Feld enthält oder nicht.

Das zweite was in dieser Zeile passiert, obwohl nicht so offensichtlich, ist folgendes. Die Variable gewonnen hast du zu Anfang auf true gesetzt. Gleichzeitig verwendest du sie hier in dieser Zeile aber wieder für die Zuweisung für sich selbst. Somit kann die Variable gewonnen das erste mal nur dann falsch (false) werden, wenn der zweite Teil der Zuweisung (also die Frage ob das Feld vom Spieler besetzt ist) nach dem and falsch ist. Es gibt also folgende Möglichkeite in dieser Zeile:
  1. gewonnen = wahr und Spieler hat das Feld besetzt, dann bleibt die Variable gewonnen auf wahr stehen.
  2. gewonnen = wahr und Spieler hat das Feld nicht besetzt, dann wird die Variable gewonnen das erste mal falsch.
Sobald die Variable gewonnen aber einmal falsch geworden ist, kann sie nie wieder für die aktuell betrachtete Reihe wahr werden und es lohnt nicht, die verbleibenden Felder der Reihe zu testen (break). Denn dann sehen die Möglichkeiten so aus:
  1. gewonnen = falsch und Spieler hat das Feld besetzt, dann bleibt die Variable gewonnen auf falsch stehen, da nicht beide Bedingungen zugleich wahr sind.
  2. gewonnen = falsch und Spieler hat das Feld nicht besetzt, dann bleibt die Variable gewonnen genauso auf falsch stehen.

Mit diesem Trick kannst du somit ganz leicht bestimmen, ob alle Felder einer Reihe tatsächlich besetzt sind.

Prima, wir sind fast fertig. Das Programm bricht nun ab, sobald alle möglichen Züge gemacht sind, oder es einen Gewinner gibt. Aber wir haben ein Problem. Wie können wir nun den Gewinner an der Konsole ausgeben? Die Methode ist_beendet? gibt uns ja nur ein true oder false zurück, den Gewinner selbst nicht.

Schau dir die Methode play_2_spieler an. Dort rufst du die Methode ist_beendet? auf. Am Ende von play_2_spieler wird der Spieler zurück gegeben, der den letzten gültigen Zug gemacht hat. Diesen Spieler könntest du in dem Hauptprogramm am Ende einfach ausgeben. Aber das reicht noch nicht. Du kannst nicht unterscheiden, warum das Spiel zu Ende ist.


# Lässt 2 Spieler miteinander spielen
def play_2_spieler(out, ein, zuege)
  spieler = [[:o, 'O'], [:x, 'X']]
  wer = 0
  while true
    ...
    break if (ist_beendet?(zuege) or !zug_okay)
    wer += 1
    wer %= 2
  end
  spieler[wer]
end

Die Methode ist_beendet? ist bisher die einzige Methode, die den möglichen Gewinner kennt. Also, dann lass sie uns dazu bewegen, diesen Gewinner nicht länger geheim zu halten.


# Schaut nach, ob das Spiel schon aus ist
def ist_beendet?(zuege)
  alle_zuege_gemacht = zuege.nil? ? false : zuege.size >= 9
  gewinner = the_winner_is(zuege)
  # Zwei Rückgabewerte in einer Liste:
  # Erster Wert: gibt an (true, false), ob das Spiel aus ist
  # Zweiter Wert: der Gewinner (oder nil, falls es keinen gibt)
  [(alle_zuege_gemacht or (gewinner != nil)), gewinner]
end

# Lässt 2 Spieler miteinander spielen
def play_2_spieler(out, ein, zuege)
  spieler = [[:o, 'O'], [:x, 'X']]
  wer = 0
  ergebnis = [false, nil]
  while true
    zug_okay = false
    until zug_okay
      out.print "#{spieler[wer][1]} ist am Zug: " 
      nummer = ein.gets.to_i
      break if nummer == 0
      spalte, zeile = nummer_in_spalte_zeile(nummer)
      zug_okay = zug_hinzu(spieler[wer][0], spalte, zeile, zuege)
    end
    spielfeld(out, zuege)
    ergebnis = ist_beendet?(zuege)
    beendet = ergebnis[0]
    break if (beendet or !zug_okay)
    wer += 1
    wer %= 2
  end
  # Rückgabewerte: der aktuelle Spieler, falls er der Gewinner ist.
  gewinner = ergebnis[1]
  if gewinner != nil
    return spieler[wer]
  else
    return nil
  end
end

Die Methode ist_beendet? gibt nun zwei Werte in einer kleinen Liste zurück: als erstes wahr oder falsch, wenn das Spiel beendet ist und als zweites den Gewinner, falls es einen gibt, oder nil, falls es keinen Gewinner gibt.

Die Methode play_2_spieler berücksichtigt nun diesen neuen Rückgabewert, holt sich aus dem ersten Element der Liste die Information, ob das Spiel aus ist und gibt selbst nicht mehr immer einen Spieler zurück, sondern nur noch, wenn dieser ein Gewinner ist. Gibt es keinen Gewinner, gibt sie nil zurück.

Nun kannst du das Hauptprogramm in lektion_13.rb anpassen, damit schließlich dort die richtigen Ausgaben gemacht werden.


# lektion_13.rb

require File.dirname(__FILE__) +  "/tictactoe" 

# Variable für die Liste der Züge; Sie ist anfangs leer.
zuege = []

# Spielfeld auf der Standardausgabe einmal ausgeben.
spielfeld(STDOUT, zuege)

gewinner = play_2_spieler(STDOUT, STDIN, zuege)

# Gibt es einen Gewinner?
if gewinner == nil
  puts "Das Spiel endet UNENTSCHIEDEN!" 
else
  puts "Der Gewinner ist #{gewinner[1]}!" 
end

Hier nochmal die gesamte Datei tictactoe.rb:


# Copyright (C) 2007 www.rubykids.de
# tictactoe.rb

# Methode, die das Spielfeld im Ausgabebereich 'out' ausgibt
# und dabei auch die in 'zuege' angegebenen Züge mit ausgibt.
# Ist für ein Feld noch kein Zug erfolgt, dann wird die
# Nummer des Feldes ausgegeben. Die Felder sind dabei von
# links nach rechts und oben nach unten von 1 bis 9 fortlaufend
# nummeriert.
def spielfeld(out, zuege)
  out.puts  "/-----------\\" 
  out.print "| " 

  print_zeile(out, 1, zuege)

  out.puts " |" 
  out.puts  "|---|---|---|" 
  out.print "| " 

  print_zeile(out, 2, zuege)

  out.puts " |" 
  out.puts  "|---|---|---|" 
  out.print "| " 

  print_zeile(out, 3, zuege)

  out.puts " |" 
  out.puts "\\-----------/" 
end

# Methode zum Ausgeben einer einzigen Zeile im Ausgabebereich 'out'.
# Welche Zeile ausgegeben werden soll ist in 'zeile' übergeben.
# Die Liste der Züge in 'zuege' brauchen wir hier, um das richtige
# Symbol (X oder O) später in den Feldern ausgeben zu können, 
# oder die Nummer des Feldes.
def print_zeile(out, zeile, zuege)
  spalte = 1
  1.upto(3) do 
    print_feld(spalte, zeile, zuege)
    out.print " | " unless spalte == 3
    spalte += 1
  end
end

# Methode, die ein bestimmtes Feld ausgibt. Entweder wird
# das Symbol für den Spieler ausgegeben, der das Feld besetzt hat,
# oder es wird die laufende Nummer des Feldes ausgegeben.
def print_feld(spalte, zeile, zuege)
  res = (spalte-1)*1 + (zeile-1)*3 + 1
  for z in zuege do
    if z[1] == spalte and z[2] == zeile
      res = (z[0] == :x ? "X" : "O") 
      break
    end
  end
  print res
end

# Methode zum Hinzufügen eines Zuges.
def zug_hinzu(wer, spalte, zeile, zuege)
  # Nicht erlauben, wenn das Feld schon besetzt ist
  erlaubt = true
  zuege.each do |zug|
    if zug[1] == spalte and zug[2] == zeile
      # Einen Zug für diese Feld gibt es schon
      erlaubt = false
      break
    end
  end
  # Ein Zug besteht aus einer kleinen Liste mit genau 3 Elementen:
  # 1. Element 'wer': gibt den Spieler an, entweder :x oder :o
  # 2. Element 'spalte': die Spalte, in der der Zug gesetzt werden soll
  # 3. Element 'zeile': die Zeile, in die der Zug gesetzt werden soll
  zuege << [wer, spalte, zeile] if erlaubt
  erlaubt
end

# Bestimmt aus der Nummer eines Feldes die Spalte und Zeile
# Angenommen Spalte und Zeilen würden von 0 bis 2 gezählt werden.
# Dann ergeben sich folgende Formeln:

# Spalte, Zeile => Nummer => Formel
# ----------------------------------------
# 0,0           => 1      => 0*1 + 0*3 + 1
# 1,0           => 2      => 1*1 + 0*3 + 1
# 2,0           => 3      => 2*1 + 0*3 + 1
# 0,1           => 4      => 0*1 + 1*3 + 1
# 1,1           => 5      => 1*1 + 1*3 + 1
# 2,1           => 6      => 2*1 + 1*3 + 1
# 0,2           => 7      => 0*1 + 2*3 + 1
# 1,2           => 8      => 1*1 + 2*3 + 1
# 2,2           => 9      => 2*1 + 2*3 + 1
def nummer_in_spalte_zeile(num)
  spalte = ((num-1) % 3)
  zeile = (((num + 2 ) / 3 ) - 1)
  [spalte+1, zeile+1]
end

# Berechnet die Feldnummer aus gegebener Spalte und Zeile
# Tabelle für Zuordnung siehe oben bei Methode nummer_in_spalte_zeile.
def nummer_aus_spalte_zeile(spalte, zeile)
  nummer = 0
  nummer = (spalte-1)*1 + (zeile-1)*3 + 1 unless (spalte.nil? or zeile.nil?)
  nummer
end

# Stellt fest, ob es einen Gewinner gibt
def the_winner_is(zuege)
  reihen = [
    [1, 2, 3],
    [4, 5, 6],
    [7, 8, 9],

    [1, 4, 7],
    [2, 5, 8],
    [3, 6, 9],

    [1, 5, 9],
    [3, 5, 7],
  ]

  # Variable für den Gewinner
  the_winner = nil

  # Für beide Spieler testen
  for spieler in [:o, :x]
    felder_besetzt = []

    for zug in zuege
      if zug[0] == spieler
        feld = nummer_aus_spalte_zeile(zug[1], zug[2])
        felder_besetzt << feld
      end
    end

    # In felder_besetzt stehen die Felder, die vom aktuellen Spieler 
    # belegt sind. Die können wir nun für alle Reihen testen.
    for reihe in reihen
      gewonnen = true
      for feld in reihe
        # gewonnen wird falsch (false), wenn das aktuelle Feld der 
        # Reihe nicht besetzt ist.
        gewonnen = (gewonnen and felder_besetzt.include?(feld))
        break if gewonnen == false # in der Reihe kein Gewinn mehr 
      end
      if gewonnen
        the_winner = spieler
        break # Gewinner gefunden, aufhören weiter zu suchen
      end
    end

    # Wenn es einen Gewinner gibt, für den nächsten gar nicht erst 
    # mehr versuchen, denn dieser kann nicht auch gleichzeitig 
    # gewonnen haben, das hätten wir beim vorherigen Zug bereits 
    # bemerkt.
    break if the_winner != nil
  end

  the_winner
end

# Schaut nach, ob das Spiel schon aus ist
def ist_beendet?(zuege)
  alle_zuege_gemacht = zuege.nil? ? false : zuege.size >= 9
  gewinner = the_winner_is(zuege)
  # Zwei Rückgabewerte in einer Liste:
  # Erster Wert: gibt an (true, false), ob das Spiel aus ist
  # Zweiter Wert: der Gewinner (oder nil, falls es keinen gibt)
  [(alle_zuege_gemacht or (gewinner != nil)), gewinner]
end

# Lässt 2 Spieler miteinander spielen
def play_2_spieler(out, ein, zuege)
  spieler = [[:o, 'O'], [:x, 'X']]
  wer = 0
  ergebnis = [false, nil]
  while true
    zug_okay = false
    until zug_okay
      out.print "#{spieler[wer][1]} ist am Zug: " 
      nummer = ein.gets.to_i
      break if nummer == 0
      spalte, zeile = nummer_in_spalte_zeile(nummer)
      zug_okay = zug_hinzu(spieler[wer][0], spalte, zeile, zuege)
    end
    spielfeld(out, zuege)
    ergebnis = ist_beendet?(zuege)
    beendet = ergebnis[0]
    break if (beendet or !zug_okay)
    wer += 1
    wer %= 2
  end
  # Rückgabewerte: der aktuelle Spieler, falls er der Gewinner ist.
  gewinner = ergebnis[1]
  if gewinner != nil
    return spieler[wer]
  else
    return nil
  end
end

Peter und Livia

Peter: Mir ist die erste Codezeile in der Methode ist_beendet? aufgefallen:

def ist_beendet?(zuege)
  alle_zuege_gemacht = zuege.nil? ? false : zuege.size >= 9
  ...
end
Was bedeuten die beiden Fragezeichen so direkt nacheinander?

Livia: Das erste Fragezeichen gehört zur Nachricht nil?, die an die Variable zuege geschickt wird. Sie fragt die Variable danach, ob sie nil ist. Falls sie das ist liefert die Nachricht true zurück. Falls die Variable aber einen echten Wert besitzt, dann liefert sie false. Das zweite Fragezeichen stammt vom ternären Operator, der ja eine abgekürzte Schreibweise für eine IF-Abfrage darstellt.

Lektion 12 - Tic-Tac-Toe, Eingabe von Zügen

Erstellt von Frithjof Sun, 16 Sep 2007 21:50:00 GMT

Tic-Tac-Toe hast du nun schon soweit implementiert, dass das Spielbrett auf der Konsole erscheint und du auch über den Aufruf einer Methode Züge hinzufügen kannst. Wir machen in dieser Lektion das Spiel interaktiv. Ruby sagt dir, wer als nächstes dran ist und wartet auf die Eingabe des Spielers. Das Eingeben von Werten, die das Rubyprogramm dann weiterverwendet, hast du ja schon in der Lektion 7 ausprobiert, wo du ein Fahrrad über die Tastatur steuern konntest. Aber alles schön der Reihe nach, denn vorher schaffst du noch etwas Ordnung in das Chaos der letzten Lektion.

Programme gliedern

In der Lektion 11 hast du genau vier Methoden entwickelt: spielfeld, print_zeile, print_feld und zug_hinzu. Unterhalb der Methoden geht dann das eigentliche Programm mit dem Erzeugen der Variablen zuege für die Liste der Züge und dem Aufruf des Spielfeldes los. Im Verlauf deiner Arbeiten an dem Spiel Tic-Tac-Toe werden noch einige Methoden hinzu kommen. Auch wird das eigentliche Programm unterhalb der Methoden umfangreicher werden. Den Überblick zu behalten wird zunehmend schwieriger. Dagegen werden wir folgendes tun:
  1. Du legst eine neue Datei mit Namen tictactoe.rb an. In dieser Datei wirst du alle Methoden verwalten.
  2. Das eigentliche Programm behältst du in einer anderen Datei, zum Beispiel für diese Lektion in der Datei lektion_12.rb. In dieser Datei machst du die Methoden aus tictactoe.rb über den require Befehlt bekannt.

So sollte es nach dem Umorganisieren bei dir auch aussehen:

Datei tictactoe.rb


# tictactoe.rb

# Methode, die das Spielfeld im Ausgabebereich 'out' ausgibt
# und dabei auch die in 'zuege' angegebenen Züge mit ausgibt.
# Ist für ein Feld noch kein Zug erfolgt, dann wird die
# Nummer des Feldes ausgegeben. Die Felder sind dabei von
# links nach rechts und oben nach unten von 1 bis 9 fortlaufend
# nummeriert.
def spielfeld(out, zuege)
  out.puts  "/-----------\\" 
  out.print "| " 

  print_zeile(out, 1, zuege)

  out.puts " |" 
  out.puts  "|---|---|---|" 
  out.print "| " 

  print_zeile(out, 2, zuege)

  out.puts " |" 
  out.puts  "|---|---|---|" 
  out.print "| " 

  print_zeile(out, 3, zuege)

  out.puts " |" 
  out.puts "\\-----------/" 
end

# Methode zum Ausgeben einer einzigen Zeile im Ausgabebereich 'out'.
# Welche Zeile ausgegeben werden soll ist in 'zeile' übergeben.
# Die Liste der Züge in 'zuege' brauchen wir hier, um das richtige
# Symbol (X oder O) später in den Feldern ausgeben zu können, 
# oder die Nummer des Feldes.
def print_zeile(out, zeile, zuege)
  spalte = 1
  1.upto(3) do 
    print_feld(spalte, zeile, zuege)
    out.print " | " unless spalte == 3
    spalte += 1
  end
end

# Methode, die ein bestimmtes Feld ausgibt. Entweder wird
# das Symbol für den Spieler ausgegeben, der das Feld besetzt hat,
# oder es wird die laufende Nummer des Feldes ausgegeben.
def print_feld(spalte, zeile, zuege)
  res = (spalte-1)*1 + (zeile-1)*3 + 1
  for z in zuege do
    if z[1] == spalte and z[2] == zeile
      res = (z[0] == :x ? "X" : "O") 
      break
    end
  end
  print res
end

# Methode zum Hinzufügen eines Zuges.
def zug_hinzu(wer, spalte, zeile, zuege)
  # Ein Zug besteht aus einer kleinen Liste mit genau 3 Elementen:
  # 1. Element 'wer': gibt den Spieler an, entweder :x oder :o
  # 2. Element 'spalte': die Spalte, in der der Zug gesetzt werden soll
  # 3. Element 'zeile': die Zeile, in die der Zug gesetzt werden soll
  zuege << [wer, spalte, zeile]
end

Datei lektion_12.rb


# lektion_12.rb

# Bekanntmachen der Methoden aus tictactoe.rb
require 'tictactoe'

# Variable für die Liste der Züge; Sie ist anfangs leer.
zuege = []

# Spielfeld auf der Standardausgabe einmal ausgeben.
spielfeld(STDOUT, zuege)

# Einen Zug zum Testen: O setzt oben links!
zug_hinzu(:o, 1, 1, zuege)

# Neues Spielfeld ausgeben
spielfeld(STDOUT, zuege)

Ab jetzt gilt: Neue Methoden gehören nur noch in die Datei tictactoe.rb, das Programm selbst erweiterst du nur noch in der Datei lektion_12.rb (in den nächsten Lektionen natürlich entsprechend).

Eingabe von Zügen

Genug der Vorarbeiten, jetzt können wir uns der eigentlichen Aufgabe dieser Lektion widmen. Zunächst ändern wir das Programm in lektion_12.rb wie folgt:


# lektion_12.rb

require 'tictactoe'

# Variable für die Liste der Züge; Sie ist anfangs leer.
zuege = []

# Spielfeld auf der Standardausgabe einmal ausgeben.
spielfeld(STDOUT, zuege)

play_2_spieler(STDOUT, STDIN, zuege)

Nach dem Anlegen der leeren Liste für die Züge und der ersten Ausgabe des noch leeren Spielfeldes, rufen wir die Methode play_2_spieler auf. Neu ist hier lediglich die Variable mit dem Namen STDIN. In der letzten Lektion hast du gelernt, dass STDOUT der Name für die Standardausgabe, also die Konsole ist, von wo aus das Programm gestartet wurde. Dann ist natürlich STDIN der Name der Standardeingabe, also ebenfalls der Konsole, von wo aus das Programm gestartet wurde. Von uns aus gesehen handelt es sich zwar immer um dieselbe Konsole, Ruby braucht aber zwei verschiedene Eimer für die Ein- bzw. Ausgabe. Ruby kann nicht in ein und denselben Eimer etwas ausgeben und gleichzeitig aus diesem etwas lesen. Die Namen STDIN und STDOUT sind übrigens die Namen der Griffe der beiden Eimer (engl. handle). Das Betriebssystem (bspw. Windows XP oder Linux) sorgen dann dafür, dass aber sowohl die Ausgaben als auch die Eingaben auf derselben Konsole erscheinen.

Mehr ist hier im Hauptprogramm zunächst nicht zu tun. Machen wir nun weiter in der Datei tictactoe.rb, in der wir ja von nun an alle Methoden verwalten wollen. Du legst in tictactoe.rb die Methode play_2_spieler wie folgt an:


def play_2_spieler(out, ein, zuege)
  spieler = [[:o, 'O'], [:x, 'X']]
end
Die Methode kennt drei Variablen out, ein und zuege, die ihr von außen vom Aufrufer mit übergeben werden. Als erstes definieren wir die beiden Spieler in der Liste spieler. Ein Spieler besteht wiederum aus einer Liste mit 2 Elementen:
  • Das erste Element ist das Symbol, das wir intern zur Unterscheidung der Spieler verwenden, also :o für den Spieler O und :x für den Spieler X.
  • Das zweite Element ist das Zeichen, das wir für die Ausgabe verwenden, also die Großbuchstaben O und X.
Was soll die Methode eigentlich genau machen? Ich schlage folgendes vor:
  1. Sie wählt den Spieler aus, der als nächstes dran ist (oder am Beginn einen, der anfängt).
  2. Sie fordert diesen Spieler auf, eine Zahl von 1 bis 9 einzugeben. Das ist die Nummer für genau das Spielfeld, auf das der Spieler seinen Stein setzen möchte. Es sollte ein noch freies Spielfeld sein.
  3. Sie berechnet für die eingegebene Nummer die Spalte und Zeile des Feldes und
  4. fügt einen neuen Zug in die Liste der Züge ein.
  5. Sie gibt nach erfolgreichem Zug das Spielfeld mit dem neuen Zustand aus.
  6. Sie stellt fest, ob das Spiel jetzt schon zu Ende ist.
  7. Falls es noch nicht zu Ende ist beginnt sie wieder von vorn (das hört sich nach einer Schleife an, oder?)

Machen wir uns ans Werk!

Den Spieler auswählen, der anfängt


def play_2_spieler(out, ein, zuege)
  spieler = [[:o, 'O'], [:x, 'X']]
  wer = 0
end

Das ist einfach. Wir legen fest, der Spieler O fängt immer an. Welcher Spieler gerade dran ist merken wir uns in der Variablen wer. Dort speichern wir den Index in der Spielerliste für den aktuellen Spieler. Der Spieler O ist der erste Spieler in der Liste, also hat wer am Anfang den Wert 0 (Null).

Der Spieler soll die Nummer eines Feldes eingeben


def play_2_spieler(out, ein, zuege)
  spieler = [[:o, 'O'], [:x, 'X']]
  wer = 0
  while true
    out.print "#{spieler[wer][1]} ist am Zug: " 
    nummer = ein.gets.to_i
    break if nummer == 0
  end
end

Natürlich brauchen wir eine Schleife. Wir nehmen die While-Schleife und machen sie zu einer Endlosschleife, indem wir die Schleifenbedingung auf true setzen. Das ist immer wahr, also hört die Schleife nie auf. Damit sie aber aufhört, wenn das Spiel zu Ende ist, oder die Spieler keine Lust mehr haben, bauen wir innerhalb der Schleife ein break zum Abbrechen ein.

In der Schleife fragen wir als erstes den aktuellen Spieler nach der Nummer für seinen nächsten Zug. Dann lesen wir von der Eingabe mit ein.gets die Nummer zunächst als String und verwandeln sie sogleich in eine natürliche Zahl mit dem Befehl to_i und speichern diese Zahl in der Variablen nummer.

Wird die Nummer gleich 0, dann brechen wir die Schleife ab. Null kann die Nummer genau dann werden, wenn der Spieler die Null eingibt, oder er gibt gar keine Zahl ein, sondern Buchstaben oder andere Zeichen. Dann kann der Befehl to_i aus der Eingabe keine natürliche Zahl erzeugen und gibt immer die Zahl Null zurück.

Bestimmen von Spalte und Zeile für die Nummer des Feldes

Ein Zug besteht aus der Angabe des Spielers und der Spalte und Zeile des Feldes, in das der Spieler seinen Stein setzen möchte. Die Nummer des Feldes haben wir nun vom Spieler erfragt. Wie lautet nun aber die Spalte und Zeile zu dieser Nummer? Wir brauchen eine weitere Methode, die diese Berechnung für uns ausführt. Schauen wir sie uns zunächst an und besprechen sie dann später.


# Bestimmt aus der Nummer eines Feldes die Spalte und Zeile
# Angenommen Spalte und Zeilen würden von 0 bis 2 gezählt werden.
# Dann ergeben sich folgende Formeln:

# Spalte, Zeile => Nummer => Formel
# ----------------------------------------
# 0,0           => 1      => 0*1 + 0*3 + 1
# 1,0           => 2      => 1*1 + 0*3 + 1
# 2,0           => 3      => 2*1 + 0*3 + 1
# 0,1           => 4      => 0*1 + 1*3 + 1
# 1,1           => 5      => 1*1 + 1*3 + 1
# 2,1           => 6      => 2*1 + 1*3 + 1
# 0,2           => 7      => 0*1 + 2*3 + 1
# 1,2           => 8      => 1*1 + 2*3 + 1
# 2,2           => 9      => 2*1 + 2*3 + 1
def nummer_in_spalte_zeile(nummer)
  spalte = (nummer - 1) % 3
  zeile  = ((nummer + 2 ) / 3 ) - 1
  # spalte und zeile beginnen aber bei 1, also 1 dazu addieren
  [spalte+1, zeile+1]
end

def play_2_spieler(out, ein, zuege)
  spieler = [[:o, 'O'], [:x, 'X']]
  wer = 0
  while true
    out.print "#{spieler[wer][1]} ist am Zug: " 
    nummer = ein.gets.to_i
    break if nummer == 0
    spalte, zeile = nummer_in_spalte_zeile(nummer)
  end
end

Die Methode nummer_in_spalte_zeile liefert uns für eine übergebene Nummer eine Liste mit zwei Zahlen: die Spalte und die Zeile an der sich das Feld auf dem Spielfeld mit der Nummer befindet.

Angenommen, Spalte und Zeile würden wir mit 0, 1, 2 abzählen. Dann berechnet sich die Spalte mit der Formel (nummer - 1) % 3. Das Feld mit der Nummer 6 hat also als Spalte die (6 - 1) % 3 = 5 % 3 = 2, also die dritte Spalte. Den Operator % (der modulo Operator oder Rest-bei-Division Operator) hast du schon kennen gelernt. Er bedeutet: Gib mir den Rest bei der Division! % 3 bedeutet somit: der Rest bei der Division durch 3. Und der Rest ist 2, wenn man die 5 durch 3 teilt. Und 2 ist der Index der dritten Spalte, wenn wir mit 0, 1, 2 zählen.

Die Berechnung der Zeile ist etwas komplizierter, aber durch etwas herumprobieren leicht aus der Tabelle im Kommentar zur Methode nummer_in_spalte_zeile abzulesen. zeile = ((nummer + 2 ) / 3 ) - 1. Hier must du nur bedenken, dass Ruby beim Dividieren mit ganzen Zahlen immer auf die nächstgelegene ganze Zahl abrundet. Für Ruby ist also 8 / 3 gleich 2. Es ist also das Ergebnis der ganzzahligen Division (mit Rest).

Bevor die Methode die berechneten Werte für Spalte und Zeile zurückgibt, muss natürlich noch eine 1 addiert werden, weil wir die Spalten und Zeilen ja doch mit 1, 2, 3 abzählen wollen.

Einfügen des neuen Zuges

Jetzt kennen wir die Spalte und Zeile, die der aktuelle Spieler besetzen will. Wir können nun den neuen Zug in die Zugliste einfügen.


def play_2_spieler(out, ein, zuege)
  spieler = [[:o, 'O'], [:x, 'X']]
  wer = 0
  while true
    out.print "#{spieler[wer][1]} ist am Zug: " 
    nummer = ein.gets.to_i
    break if nummer == 0
    spalte, zeile = nummer_in_spalte_zeile(nummer)
    zug_hinzu(spieler[wer][0], spalte, zeile, zuege)
  end
end

Aber Moment mal! Was ist, wenn der Spieler eine Nummer für ein Feld eingibt, das bereits von seinem Gegenspieler besetzt wurde? Das dürfen wir nicht zulassen. Wir müssen die Methode zug_hinzu aus der letzten Lektion abändern:


# Methode zum Hinzufügen eines Zuges.
def zug_hinzu(wer, spalte, zeile, zuege)
  # Nicht erlauben, wenn das Feld schon besetzt ist
  erlaubt = true
  zuege.each do |zug|
    if zug[1] == spalte and zug[2] == zeile
      # Einen Zug für diese Feld gibt es schon
      erlaubt = false
      break
    end
  end
  # Ein Zug besteht aus einer kleinen Liste mit genau 3 Elementen:
  # 1. Element 'wer': gibt den Spieler an, entweder :x oder :o
  # 2. Element 'spalte': die Spalte, in der der Zug gesetzt werden soll
  # 3. Element 'zeile': die Zeile, in die der Zug gesetzt werden soll
  # ... aber nur, wenn der Zug erlaubt ist!
  zuege << [wer, spalte, zeile] if erlaubt
  erlaubt
end

Die Methode zug_okay liefert nun true zurück, wenn der Zug erlaubt ist und gemacht wurde, andernfalls liefert sie false zurück. Das können wir benutzen, um den Spieler erneut um die Eingabe einer Nummer zu bitten. Wir ändern also unsere bisherige Methode play_2_spieler wie folgt ab:


def play_2_spieler(out, ein, zuege)
  spieler = [[:o, 'O'], [:x, 'X']]
  wer = 0
  while true
    zug_okay = false
    until zug_okay
      out.print "#{spieler[wer][1]} ist am Zug: " 
      nummer = ein.gets.to_i
      break if nummer == 0
      spalte, zeile = nummer_in_spalte_zeile(nummer)
      zug_okay = zug_hinzu(spieler[wer][0], spalte, zeile, zuege)
    end
  end
end

Wir merken uns in der Variablen zug_okay, ob der Zug gemacht werden konnte oder nicht. Solange diese Variable den Wert false hat, wiederholen wir die Aufforderung zur Eingabe eines Zuges. Dafür nehmen wir eine neue Schleife, die Until-Schleife. Sie führt einen Codeblock solange aus, bis die Bedingung wahr wird (until, engl. solange bis) .

Den ersten Wert von zug_okay setzen wir auf false, somit läuft die Until-Schleife zumindest einmal durch.

Den Rückgabewert von zug_hinzu nehmen wir als neuen Wert für zug_okay. Hat der Spieler sich nicht vertippt und eine gültige Zahl eingegeben, dann wurde der Zug hinzugefügt und die Variable zug_okay hat den Wert true. Dann ist die Until-Schleife beendet.

Spielfeld nach dem Zug ausgeben

Das ist einfach, dafür haben wir ja schon eine Methode.


def play_2_spieler(out, ein, zuege)
  spieler = [[:o, 'O'], [:x, 'X']]
  wer = 0
  while true
    zug_okay = false
    until zug_okay
      out.print "#{spieler[wer][1]} ist am Zug: " 
      nummer = ein.gets.to_i
      break if nummer == 0
      spalte, zeile = nummer_in_spalte_zeile(nummer)
      zug_okay = zug_hinzu(spieler[wer][0], spalte, zeile, zuege)
    end
    spielfeld(out, zuege)
  end
end

Feststellen, ob das Spiel schon zu Ende ist


def play_2_spieler(out, ein, zuege)
  spieler = [[:o, 'O'], [:x, 'X']]
  wer = 0
  while true
    zug_okay = false
    until zug_okay
      out.print "#{spieler[wer][1]} ist am Zug: " 
      nummer = ein.gets.to_i
      break if nummer == 0
      spalte, zeile = nummer_in_spalte_zeile(nummer)
      zug_okay = zug_hinzu(spieler[wer][0], spalte, zeile, zuege)
    end
    spielfeld(out, zuege)
    break if ist_beendet?(zuege) or !zug_okay
  end
end

Wir verlassen die While-Schleife, wenn das Spiel beendet ist, oder der Spieler bewusst eine 0 (Null) oder einen Buchstaben eingegeben hatte, um das Spiel zu beenden. Dann nämlich hatte die Until-Schleife keine Chance, die Variable zug_okay auf true zu setzen, weil zuvor das break die Until-Schleife unterbrochen hat.

Es bleibt noch die Methode ist_beendet? zu implementieren.


def ist_beendet?(zuege)
  zuege.size >= 9
end

Das Spiel ist aus, wenn 9 oder mehr Züge (mehr als 9 können eigentlich nicht vorkommen, wenn zug_hinzu richtig arbeitet) gemacht wurden. Wir schicken dazu die Nachricht size (engl. Größe) an die Zugliste und erhalten damit die Anzahl der Elemente in der Zugliste zurück.

Es könnte aber passieren, dass wir aus Versehen eine ungültige Zugliste übergeben, eine die es gar nicht gibt. Wir testen also lieber vorher, ob die Variable zuege nicht den Wert nil hat. Dazu verwenden wir den ternären Operator, den du auch schon kennst, weil man so den Test in einer Zeile unterbringt:


def ist_beendet?(zuege)
  zuege == nil ? false : zuege.size >= 9
end

Nächster Spieler ist dran


def play_2_spieler(out, ein, zuege)
  spieler = [[:o, 'O'], [:x, 'X']]
  wer = 0
  while true
    zug_okay = false
    until zug_okay
      out.print "#{spieler[wer][1]} ist am Zug: " 
      nummer = ein.gets.to_i
      break if nummer == 0
      spalte, zeile = nummer_in_spalte_zeile(nummer)
      zug_okay = zug_hinzu(spieler[wer][0], spalte, zeile, zuege)
    end
    spielfeld(out, zuege)
    break if ist_beendet?(zuege) or !zug_okay
    wer += 1
    wer %= 2
  end
  spieler[wer]
end

Wir erhöhen den Index für die Spielerliste um 1. Es gibt aber nur den Index 0 und den Index 1. Wenn wir schon bei 1 sind und noch eines dazu addieren wären wir bei 2. Somit machen wir anschließend noch einmal eine Division durch 2 mit Rest und nehmen den Rest als neuen Wert für unseren Index. So können wir sicherstellen, dass der Index immer abwechselnd 0 oder 1 ist.

Irgendwann erfolgt durch den Aufruf von ist_beendet? ein break in der While-Schleife, spätestens nach 9 gültigen Zügen. Dann geben wir als letztes in unserer Methode das Symbol des aktuellen Spielers zurück. Das brauchen wir dann in den nächsten Lektionen, um erkennen zu können, wer den letzten Zug gemacht hat und evtl. der Sieger ist.

Zum Schluß hier alle Methoden, die neuen und die, die wir geändert haben:


# tictactoe.rb

# Methode, die das Spielfeld im Ausgabebereich 'out' ausgibt
# und dabei auch die in 'zuege' angegebenen Züge mit ausgibt.
# Ist für ein Feld noch kein Zug erfolgt, dann wird die
# Nummer des Feldes ausgegeben. Die Felder sind dabei von
# links nach rechts und oben nach unten von 1 bis 9 fortlaufend
# nummeriert.
def spielfeld(out, zuege)
  out.puts  "/-----------\\" 
  out.print "| " 

  print_zeile(out, 1, zuege)

  out.puts " |" 
  out.puts  "|---|---|---|" 
  out.print "| " 

  print_zeile(out, 2, zuege)

  out.puts " |" 
  out.puts  "|---|---|---|" 
  out.print "| " 

  print_zeile(out, 3, zuege)

  out.puts " |" 
  out.puts "\\-----------/" 
end

# Methode zum Ausgeben einer einzigen Zeile im Ausgabebereich 'out'.
# Welche Zeile ausgegeben werden soll ist in 'zeile' übergeben.
# Die Liste der Züge in 'zuege' brauchen wir hier, um das richtige
# Symbol (X oder O) später in den Feldern ausgeben zu können, 
# oder die Nummer des Feldes.
def print_zeile(out, zeile, zuege)
  spalte = 1
  1.upto(3) do 
    print_feld(spalte, zeile, zuege)
    out.print " | " unless spalte == 3
    spalte += 1
  end
end

# Methode, die ein bestimmtes Feld ausgibt. Entweder wird
# das Symbol für den Spieler ausgegeben, der das Feld besetzt hat,
# oder es wird die laufende Nummer des Feldes ausgegeben.
def print_feld(spalte, zeile, zuege)
  res = (spalte-1)*1 + (zeile-1)*3 + 1
  for z in zuege do
    if z[1] == spalte and z[2] == zeile
      res = (z[0] == :x ? "X" : "O") 
      break
    end
  end
  print res
end

# Methode zum Hinzufügen eines Zuges.
def zug_hinzu(wer, spalte, zeile, zuege)
  # Nicht erlauben, wenn das Feld schon besetzt ist
  erlaubt = true
  zuege.each do |zug|
    if zug[1] == spalte and zug[2] == zeile
      # Einen Zug für diese Feld gibt es schon
      erlaubt = false
      break
    end
  end
  # Ein Zug besteht aus einer kleinen Liste mit genau 3 Elementen:
  # 1. Element 'wer': gibt den Spieler an, entweder :x oder :o
  # 2. Element 'spalte': die Spalte, in der der Zug gesetzt werden soll
  # 3. Element 'zeile': die Zeile, in die der Zug gesetzt werden soll
  zuege << [wer, spalte, zeile] if erlaubt
  erlaubt
end

# Berechnete die spalte und zeile für die Nummer eines Feldes
def nummer_in_spalte_zeile(num)
  spalte = ((num-1) % 3)
  zeile = (((num + 2 ) / 3 ) - 1)
  [spalte+1, zeile+1]
end

# Schaut nach, ob das Spiel schon aus ist
def ist_beendet?(zuege)
  zuege == nil ? false : zuege.size >= 9
end

# Lässt 2 Spieler miteinander spielen
def play_2_spieler(out, ein, zuege)
  spieler = [[:o, 'O'], [:x, 'X']]
  wer = 0
  while true
    zug_okay = false
    until zug_okay
      out.print "#{spieler[wer][1]} ist am Zug: " 
      nummer = ein.gets.to_i
      break if nummer == 0
      spalte, zeile = nummer_in_spalte_zeile(nummer)
      zug_okay = zug_hinzu(spieler[wer][0], spalte, zeile, zuege)
    end
    spielfeld(out, zuege)
    break if ist_beendet?(zuege) or !zug_okay
    wer += 1
    wer %= 2
  end
  spieler[wer]
end

Peter und Livia

Peter: Das Spiel ist doch nicht erst beendet, wenn 9 gültige Züge gemacht wurden! So wie das Programm bisher läuft, kann man noch weiterspielen, obwohl schon einer der Spieler 3 Steine in einer Reihe hat.

Livia: Das stimmt. Das Rubyprogramm kann noch nicht erkennen, wann ein Spieler gewonnen hat. Das könntest du noch in die Methode ist_beendet? einbauen. Warte bis zur nächsten Lektion.

Peter: Die Methode ist_beendet? hat ein Fragezeichen im Namen. Was bedeutet das?

Livia: Alle Methoden in Ruby, die wahr oder falsch (true oder false) zurückgeben, sollte man durch dieses an den Namen angehängte Fragezeichen von außen schon als solche Methode erkennbar machen. Es zwingt dich aber niemand dazu. Nur ist es beim Lesen des Codes schöner.

Lektion 11 - Tic-Tac-Toe, Der Anfang 1

Erstellt von Frithjof Sun, 09 Sep 2007 21:53:00 GMT

Nach der Lektion 10 hatten wir eine kleine Pause eingelegt und in verschiedenen Theorie-Lektionen grundlegende Fähigkeiten beim Umgang mit der Programmiersprache Ruby erworben.

In dieser Lektion fangen wir, wie schon lange angekündigt, damit an, ein Spiel in Ruby zu entwickeln. Ich habe für dich das Spiel Tic-Tac-Toe (engl. Infos unter Tictactoe) ausgesucht. Die Regeln sind leicht zu verstehen, zumindest für dich und mich als Menschen. Ein Computer hat es da etwas schwerer. Wir werden es ihm aber auch beibringen.

Du hast also in den nächsten 7 oder mehr Lektionen einiges vor. Wir teilen uns die Arbeit etwa folgendermaßen ein:

  1. Das Spielbrett Ausgabe des Spielbretts mit den aktuell platzierten Spielsteinen.
  2. Eingabe von Zügen Zwei Spieler sollen nacheinander ihre Züge eingeben können.
  3. Der Computer als Gegner Du kannst alleine gegen den Computer spielen. Der Computer muss die Regeln des Spiels lernen.

Bevor wir in Ruby loslegen, schauen wir uns gemeinsam zunächst die Spielregeln von Tic-Tac-Toe an.

Kurze Spielanleitung

Das Spielbrett besteht aus 3 mal 3 Kästchen. Die zwei Spieler erhalten jeder eine Sorte Steine, die sie abwechselnd in die Kästchen setzen. Gewinner ist, wer zuerst 3 gleiche Steine in einer Reihe, Spalte oder Diagonale setzt. Du kannst das Spiel natürlich auch auf Papier mit Kreuzchen und Kreisen als Spielsteine spielen. Wenn beide Spieler optimal spielen, läuft Tic-Tac-Toe immer auf ein Unentschieden hinaus. Du kannst also nur gewinnen, wenn dein Gegner einen Fehler macht, egal wie du dich anstrengst. Umgekehrt gilt das natürlich auch.

Im folgenden Bild siehst du den Verlauf eines Spieles von links nach rechts und oben nach unten:

Spieler Kreis fängt an und setzt oben links in die Ecke. Spieler Kreuz hat 3 Möglichkeiten direkt neben ihn zu setzen und entscheidet sich für die Mitte, hätte aber auch sonst irgendwo seinen Stein setzen können. Dann ist Kreis wieder dran und versucht nun die erste Spalte mit seinen blauen Kreisen aufzufüllen. Das verhindert Kreuz im nächsten Zug durch seinen Stein unten links. Kreis gibt nicht auf und versucht nun die erste Zeile aufzufüllen mit seinem Stein oben rechts. Aber Kreuz passt auf und vermasselt ihm das auch durch seinen Stein in die Mitte oben. Auweia, Kreis sieht, dass Kreuz im nächsten Zug die zweite Spalte für sich holen könnte und es bleibt ihm nichts anderes übrig, als die Mitte unten zu besetzen. Jetzt ist das Spiel eigentlich schon gelaufen, die beiden freien Felder können von beiden beliebig gesetzt werden, keiner kann mehr gewinnen—Unentschieden!

Solltest du das Spiel noch nicht kennen, dann übe es zunächst ein paar mal mit Mama solange, bis du es ganz verstanden hast. Vielleicht hast du ja schon ein paar Ideen, wie du garantiert nie verlieren kannst, also höchstens mit Unentschieden das Spielfeld verlässt?

Das Spielbrett in Ruby

Okay, lass uns loslegen, das Spielfeld in Ruby aufzuzeichnen! Wir werden die 9 Felder des Spielfelds von links nach rechts und von oben nach unten fortlaufend durchnummerieren. Ist ein Feld leer, also hat noch niemand der beiden Spieler es besetzt, so geben wir die Nummer des Feldes aus. Die Nummer können wir dann später verwenden, um vom Spieler zu erfragen, in welches Feld er seinen Stein setzen möchte.

Ist ein Feld aber schon von einem Spieler besetzt, so geben wir im Feld das Zeichen für den Spieler aus. Wir legen fest: Spieler 1 hat ein O (der Buchstabe Ohh für den blauen Kreis) und Spieler 2 bekommt ein X (für das rote Kreuz).

Wenn wir das Spielfeld ausgeben wollen, müssen wir also die bereits gemachten Züge berücksichtigen. Wir müssen sie uns also irgendwo merken. Na klar, wir merken sie uns natürlich in einer Liste.


  # Variable für die Liste der Züge; Sie ist anfangs leer.
  zuege = []
Wir legen weiter fest, dass ein Zug aus 3 Teilen besteht:
  1. Welcher Spieler?
  2. Welche Spalte?
  3. Welche Zeile?

Somit können wir einen Zug wiederum als eine kleine Liste mit immer 3 Elementen speichern. Wir definieren dafür eine Methode, der wir diese 3 Elemente übergeben und zusätzlich noch die aktuelle Liste aller Züge. Die Methode fügt dann den neuen Zug der Liste hinzu. Das neue Element in der Zug-Liste ist also selbst eine kleine Liste!


  # Methode zum Hinzufügen eines Zuges.
  def zug_hinzu(wer, spalte, zeile, zuege)
    # Ein Zug besteht aus einer kleinen Liste mit genau 3 Elementen:
    # 1. Element 'wer': gibt den Spieler an, entweder :x oder :o
    # 2. Element 'spalte': die Spalte, in der der Zug gesetzt werden soll
    # 3. Element 'zeile': die Zeile, in die der Zug gesetzt werden soll
    zuege << [wer, spalte, zeile]
  end

Bis hierher dürfte es noch nicht allzu schwer gewesen sein, oder? Dafür wird es aber nun etwas knifflig. Wir brauchen eine Methode, die uns ein bestimmtes Feld ausgibt. Schauen wir sie uns zunächst an.


01  # Methode, die ein bestimmtes Feld ausgibt. Entweder wird
02  # das Symbol für den Spieler ausgegeben, der das Feld besetzt hat,
03  # oder es wird die laufende Nummer des Feldes ausgegeben.
04  def print_feld(spalte, zeile, zuege)
05    feld = (spalte-1)*1 + (zeile-1)*3 + 1
06    for zug in zuege do
07      if zug[1] == spalte and zug[2] == zeile
08        feld = (zug[0] == :x ? "X" : "O") 
09        break
10      end
11    end
12    print feld
13  end

Die Methode ist zwar kurz, aber es passiert hier eine Menge. Ich habe die Zeilen daher einmal nummeriert. Die Methode bekommt nun also die aktuelle Zeile und Spalte des Feldes, das sie ausgeben soll. Zusätzlich bekommt sie noch die Liste aller bisher gemachten Züge.

Die Methode muss nun folgendes machen:
  1. Die laufende Nummer für das Feld berechnen.
  2. Nachschauen, ob für das Feld bereits ein Zug existiert.
  3. Wenn ein Zug existiert, feststellen, welcher Spieler ihn gemacht hat und dann die laufende Nummer mit dem entsprechenden Zeichen für den Spieler überschreiben.

Schauen wir uns zuerst die Berechnung der laufenden Nummer von 1 bis 9 an. Das passiert in Zeile 05 in obigem Code.


...
05    feld = (spalte-1)*1 + (zeile-1)*3 + 1
...

Die laufende Nummer wird durch eine mathematische Formel aus der Spalte und der Zeile bestimmt. Erforderlich ist dabei, dass die Zeilen und Spalten jeweils von 1 bis 3 von links nach rechts bzw. von oben nach unten gezählt werden. Wenn du nicht glaubst, dass das funktioniert, dann probier einfach alle Möglichkeiten für die Formel aus. Zum Beispiel, welche Nummer muss in Spalte 3 und Zeile 2 stehen (das Feld in der Mitte ganz rechts außen)? Setzen wir die Werte in die Formel ein:


...
05    feld = (3-1)*1 + (2-1)*3 + 1
...

Wenn du das ausrechnest, erhälst du die gesuchte Zahl 6:


...
05    feld = 6
...

Weiter geht’s! Nachdem wir nun die Nummer vorsorglich schon mal berechnet haben, müssen wir nun doch noch schauen, ob nicht vielleicht schon ein Zug eines Spielers dieses Feld besetzt hat. Das machen wir in der for-Schleife in den Zeilen 06 bis 11.


...
06    for zug in zuege do
07      if zug[1] == spalte and zug[2] == zeile
08        feld = (zug[0] == :x ? "X" : "O") 
09        break
10      end
11    end
...

Hier gehen wir alle Züge durch, schauen, ob es einen Zug gibt, der für die aktuelle Spalte und Zeile zutrifft. Bedenke, dass ein Zug aus einer kleinen Liste mit 3 Elementen besteht. Das zweite Element ist die Spalte, die wir mit dem Index 1 erreichen, das dritte Element ist die Zeile, die wir mit dem Index 2 erreichen und jeweils mit den Werten der aktuell auszugebenden Spalte und Zeile vergleichen können:


...
07      if zug[1] == spalte and zug[2] == zeile
...

Wenn die IF-Abfrage true liefert, dann haben wir einen Zug gefunden, der auf das aktuelle Feld zutrifft. Dann schauen wir uns an, wer der beiden Spieler diesen Zug gemacht hat. Den Spieler merkten wir uns ja im ersten Element des Zuges, also beim Index 0.


...
08        feld = (zug[0] == :x ? "X" : "O") 
...

Hallo? Was bedeutet denn diese Zeile? Hier ist etwas neu, wie du sicher schon bemerkt hast. Wir verwenden hier den sogennanten dreiwertigen Vergleichsoperator (ternärer Operator). Den stellst du dir am einfachsten als eine IF-Abfrage in nur einer Zeile vor. Statt der Zeile oben hätten wir auch schreiben können:


  if zug[0] == :x
    feld = "X" 
  else
    feld = "O" 
  end

Beim dreiwertigen Vergleich ersetzt man also das vorangehende if durch ein nachgestelltes ? und das else durch einen : (Doppelpunkt). Das end fällt weg, weil man sowieso nur eine Zeile braucht.

Aber noch etwas ist neu! Was soll eigentlich :x bedeuten? Das nennt man in Ruby ein Symbol. Das ist wiederum eine Kurzschreibweise für eine Zeichenkette. Statt :x könnten wir auch "x" schreiben. Das richtige Gespühr, wann es sinnvoll ist ein Symbol und wann eine Zeichenkette zu verwenden, wirst du mit der Zeit noch bekommen.

Die Methode für das Ausgeben eines einzigen Feldes hat es also ganz schön in sich. Vielleicht musst du dir das alles in Ruhe erst noch einmal durchdenken.

Wir können nun ein Feld ausgeben. Eine Zeile wollen wir als nächstes ausgeben. Sie besteht immer aus 3 Feldern, die wir von links nach rechts ausgeben. Das macht folgende Methode:


  # Methode zum Ausgeben einer einzigen Zeile im Ausgabebereich 'out'.
  # Welche Zeile ausgegeben werden soll ist in 'zeile' übergeben.
  # Die Liste der Züge in 'zuege' brauchen wir hier, um das richtige
  # Symbol (X oder O) später in den Feldern ausgeben zu können, 
  # oder die Nummer des Feldes.
  def print_zeile(out, zeile, zuege)
    spalte = 1
    1.upto(3) do 
      print_feld(spalte, zeile, zuege)
      out.print " | " unless spalte == 3
      spalte += 1
    end
  end

Das sieht nun nicht mehr so schwierig aus. Wir rufen 3 mal die Methode für die Ausgabe eines Feldes auf. Die Zeile erhalten wir vom Aufruf, die Spalten zählen wir in der Methode selber von 1 bis 3 hoch. Nach jedem Feld geben wir einen senkrechten Strich aus, der den Rand des Kästchens markieren soll.

Wir haben es endlich geschafft. Hier ist die oberste Methode, die nun das gesamte Spielfeld ausgibt, indem sie alle Zeilen ausgibt:


  # Methode, die das Spielfeld im Ausgabebereich 'out' ausgibt
  # und dabei auch die in 'zuege' angegebenen Züge mit ausgibt.
  def spielfeld(out, zuege)
    out.puts  "/-----------\\" 
    out.print "| " 

    print_zeile(out, 1, zuege)

    out.puts " |" 
    out.puts  "|---|---|---|" 
    out.print "| " 

    print_zeile(out, 2, zuege)

    out.puts " |" 
    out.puts  "|---|---|---|" 
    out.print "| " 

    print_zeile(out, 3, zuege)

    out.puts " |" 
    out.puts "\\-----------/" 
  end

Du stimmst mir sicher zu, wenn ich sage, das reicht für heute!

Zum Schluß nochmal das gesamte Programm, das unser Spielfeld ausgibt. Zum Testen fügen wir einmal nach der ersten Ausgabe einen Zug in die Zug-Liste hinzu und geben das Spielfeld ein zweites mal aus. Die Variable STDOUT ist eine Konstante und ist der Name des Ausgabebereichs, in den wir ausgeben wollen. Die Standardausgabe (STDOUT) ist die Konsole, die wir bisher auch schon immer verwendet haben (nur hatten wir sie bisher nie bei ihrem richtigen Namen gerufen).


# lektion_11.rb

# Methode, die das Spielfeld im Ausgabebereich 'out' ausgibt
# und dabei auch die in 'zuege' angegebenen Züge mit ausgibt.
# Ist für ein Feld noch kein Zug erfolgt, dann wird die
# Nummer des Feldes ausgegeben. Die Felder sind dabei von
# links nach rechts und oben nach unten von 1 bis 9 fortlaufend
# nummeriert.
def spielfeld(out, zuege)
  out.puts  "/-----------\\" 
  out.print "| " 

  print_zeile(out, 1, zuege)

  out.puts " |" 
  out.puts  "|---|---|---|" 
  out.print "| " 

  print_zeile(out, 2, zuege)

  out.puts " |" 
  out.puts  "|---|---|---|" 
  out.print "| " 

  print_zeile(out, 3, zuege)

  out.puts " |" 
  out.puts "\\-----------/" 
end

# Methode zum Ausgeben einer einzigen Zeile im Ausgabebereich 'out'.
# Welche Zeile ausgegeben werden soll ist in 'zeile' übergeben.
# Die Liste der Züge in 'zuege' brauchen wir hier, um das richtige
# Symbol (X oder O) später in den Feldern ausgeben zu können, 
# oder die Nummer des Feldes.
def print_zeile(out, zeile, zuege)
  spalte = 1
  1.upto(3) do 
    print_feld(spalte, zeile, zuege)
    out.print " | " unless spalte == 3
    spalte += 1
  end
end

# Methode, die ein bestimmtes Feld ausgibt. Entweder wird
# das Symbol für den Spieler ausgegeben, der das Feld besetzt hat,
# oder es wird die laufende Nummer des Feldes ausgegeben.
def print_feld(spalte, zeile, zuege)
  res = (spalte-1)*1 + (zeile-1)*3 + 1
  for z in zuege do
    if z[1] == spalte and z[2] == zeile
      res = (z[0] == :x ? "X" : "O") 
      break
    end
  end
  print res
end

# Methode zum Hinzufügen eines Zuges.
def zug_hinzu(wer, spalte, zeile, zuege)
  # Ein Zug besteht aus einer kleinen Liste mit genau 3 Elementen:
  # 1. Element 'wer': gibt den Spieler an, entweder :x oder :o
  # 2. Element 'spalte': die Spalte, in der der Zug gesetzt werden soll
  # 3. Element 'zeile': die Zeile, in die der Zug gesetzt werden soll
  zuege << [wer, spalte, zeile]
end

# Variable für die Liste der Züge; Sie ist anfangs leer.
zuege = []

# Spielfeld auf der Standardausgabe einmal ausgeben.
spielfeld(STDOUT, zuege)

# Einen Zug zum Testen: O setzt oben links!
zug_hinzu(:o, 1, 1, zuege)

# Neues Spielfeld ausgeben
spielfeld(STDOUT, zuege)

Peter und Livia

Peter: Ich bin enttäuscht! Ich hatte mich auf ein Spiel mit Grafik und Maus und so gefreut. Stattdessen wieder nur das olle schwarze Fenster.

Livia: Das ist eigentlich keine schlechte Idee—für später. Aber meinst du nicht, dass das Spiel auf der Konsole erst einmal genug an Arbeit ist? Würden wir gleich mit Grafik und Mausbewegungen loslegen, hätten wir ja noch mehr zu tun. Das würde die Sache nicht leichter machen.