RGSS/Tutorials/Rubykurs 3 - RGSS Teil 4

Aus Scientia
Version vom 22. März 2011, 18:55 Uhr von KaiD (Diskussion | Beiträge)

(Unterschied) ← Nächstältere Version | Aktuelle Version (Unterschied) | Nächstjüngere Version → (Unterschied)
Wechseln zu: Navigation, Suche

Window_Selectable

Wie bereits zuvor gesagt wurde, ist Window_Selectable die Klasse für tabellarische Menüs. Window_Command ist ihre Subklasse und definiert Menüs, die nur aus einer Spalte bestehen und deren Schaltflächen Texte sind. Ideal für unser MultipleChoice Menü, aber oftmals reichen uns die Möglichkeiten, die uns Window_Command gibt, nicht aus. Was ist, wenn wir ein vertikales Menü haben wollen (Die Texte also nebeneinander statt untereinander stehen)? Oder wenn wir Icons statt Texte als Auswahlflächen haben möchten? In diesem Fall müssen wir unsere eigene Subklasse von Window_Selectable schreiben. Zu diesem Zweck sollten wir uns erst einmal anschauen wie Window_Selectable aufgebaut ist. Der Source-Code dieser Klasse steht im Scripteditor. Ihr könnt ihn euch also anschauen und nachvollziehen.


Schauen wir uns als erstes die Instanzvariablen der Window_Selectable Klasse an.

class Window_Selectable < Window_Base
  #--------------------------------------------------------------------------
  # * Public Instance Variables
  #--------------------------------------------------------------------------
  attr_reader   :index                    # cursor position
  attr_reader   :help_window              # help window

Die Klasse definiert Getter-Methoden für die Instanzvariablen @index und @help_window. Die Variable @help_window darf auf eine Instanz der Klasse Window_Help zeigen. Sie ist dafür verantwortlich Beschreibungstexte über dem Auswahlmenü anzuzeigen. Wir kennen das vom Item-Menü, wo über der Itemauswahl auch gleich eine Beschreibung des Items steht. Mit dem Window_Help beschäftigen wir uns später, kommen wir erstmal zur Variable @index. Man könnte von einem tabellarischen (also zweidimensionalen) Menü ja eigentlich erwarten, dass es nicht eine, sondern zwei Index Variablen definiert (eine für die Reihe, eine für die Spalte). Window_Selectable macht es aber anders (und meiner Meinung nach auch besser). Bevor wir näher auf die Window_Selectable Klasse eingehen, machen wir noch kurz einen Exkurs wie man solche zweidimensionalen Tabellen in Ruby umsetzen kann (wo die Array-Klasse ja nur mit eindimensionalen Daten umgehen kann).

Zweidimensionale Tabellen

Es verwendet nur einen Index und nummeriert alle Auswahlfelder von oben links zeilenweise nach unten rechts durch. Im folgenden habe ich mal eine Beispieltabelle aufgeschrieben und sie durchnummeriert. Die Tabelle hat 4 Spalten und 3 Zeilen.

0 1 2 3
4 5 6 7
8 9 10 11

Wie können wir in so einer Tabelle aus einem Index herausrechnen in welcher Zeile und Spalter er liegt? Dafür gibt es eine einfache Rechnung. Voraussetzung ist, dass wir wissen wie viele Spalten unsere Tabelle hat. Denn dann können wir die Zeile, in der unser Index liegt bekommen, in dem wir den Index einfach durch die Anzahl der Spalten dividieren. Probieren wir es testweise aus: In welcher Zeile liegt der Index 6? Wir haben in unserer Tabelle 4 Spalten, also müssen wir 6 durch 4 dividieren. 6 / 4 = 1. Klar, denn wir rechnen hier mit Integer, also Ganzzahlen. Darum kommt natürlich keine Kommazahl raus. Wenn wir in unsere Tabelle schauen, und uns nochmal ins Gedächtnis rufen das wir in Ruby immer mit 0 anfangen zu zählen, dann stimmt das Ergebnis. Die 6 steht in der mittleren Zeile, also in der zweiten. Von 0 angefangen zu zählen wäre das die Zeile Nummer 1. Noch ein Versuch: In welcher Zeile liegt der Index 9? 9 / 4 = 2. Also in der dritten Zeile. Stimmt ebenfalls wieder.

An die Spaltennummer kommen wir, in dem wir den Rest aus der Division nehmen. 6 / 4 = 1 Rest 2. Und tatsächlich, die 6 steht in der dritten Spalte. 9 / 4 = 2 Rest 1, und die 9 steht auch in der zweiten Spalte. In Ruby verwenden wir natürlich die Modulo-Operation um an den Rest zu kommen.

Fassen wir zusammen: Wir haben eine Tabelle, von der wir wissen, wie viele Spalten sie hat. Außerdem haben wir einen Index, der auf irgendeinen Tabelleneintrag zeigt. Um herauszufinden auf welche Zeile und Spalte er zeigt, rechnen wir also:

zeile = index / spaltenzahl
spalte = index % spaltenzahl

Die Rückrechnung geht ähnlich einfach. Wir haben eine Spalte und Zeile gegeben und wollen den Index wissen. Also multiplizieren wir die Zeile mit der maximalen Spaltenzahl und addieren die Spalte drauf.

index = zeile * spaltenzahl + spalte


Mit diesen beiden Rechnungen können wir ganz einfach eine zweidimensionale Array-Klasse schreiben:

# ein zweidimensionaler Array
class Array2D
  # zeilenzahl: maximale Anzahl an Zeilen
  # spaltenzahl: maximale Anzahl an Spalten
  # init: optionaler Parameter. Tabelle wird mit diesem Wert gefüllt
  def initialize(zeilenzahl, spaltenzahl, init=nil)
    @zeilenzahl = zeilenzahl
    @spaltenzahl = spaltenzahl
    # inhalt ist ein Array mit den Tabellenwerten
    # initialisiere ihn mit dem init-Wert
    @inhalt = Array.new(zeilenzahl * spaltenzahl, init)
  end
 
  # gibt den Index für eine gegebene Zeile und Spalte zurück
  def get_index(zeile, spalte)
    if zeile >= @zeilenzahl || spalte >= @spaltenzahl
      # hier sollte eigentlich eine richtige Fehlermeldung hin. Aber dazu kommen
      # wir ein andern mal ^^
      p "FEHLER! Du greifst auf einen Tabelleneintrag zu der gar nicht existiert"
    else 
      zeile * @spaltenzahl + spalte
    end
  end
 
  # greife auf den Inhalt der Tabelle zu
  def [](zeile, spalte)
    @inhalt[get_index(zeile, spalte)]
  end
 
  # ändere den Inhalt der Tabelle
  def []=(zeile, spalte, wert)
    @inhalt[zeile, spalte] = wert
  end
 
end

Das meiste sollte nicht unbekannt sein. Unser 2D-Array kennt die Attribute @zeilenzahl, @spaltenzahl und @inhalt. Letzteres ist ein einfacher Array. Über die get_index Methode wandeln wir die zweidimensionalen Zeilen- und Spalten-Angaben in einen eindimensionalen Index um. Mit diesem greifen wir dann auf den Array zu. In dieser Klasse seht ihr auch die Array-Operatoren [] und []= in Aktion. Wie früher schon einmal erwähnt kann man alle Methoden, die die Objekte der Ruby-API beherrschen, auch selbst definieren. So auch die Methoden, die von der Array Klasse verwendet werden um Zugriff auf ihre Elemente zu gewähren. Testen wir unsere Klasse mal aus:

tabelle = Array2D.new(5, 5, 0)
print( tabelle[0, 0] ) #=> 0
tabelle[0, 0] = 49
print( tabelle[0, 0] ) #=> 49
tabelle[1, 2] = 6
tabelle[2, 1] = tabelle[1, 2] * 2
# auch folgendes geht
tabelle[1, 2] += 1
print( tabelle[1, 2] ) #=> 7
tabelle[5, 0] #=> FEHLER! Du greifst auf einen Tabelleneintrag zu der gar nicht existiert

Icon scripting.png

Erweitere die Array2D Klasse um folgende Methoden:

  • to_s : Sollte den Inhalt der Tabelle ausgeben
  • fill : Sollte alle Felder der Tabelle mit dem gegebenen Wert füllen
  • diag : Soll einen Array zurückgeben, der die Elemente auf der Hauptdiagonale enthält. Die Hauptdiagonale sind alle Tabellenfelder, bei denen Zeile und Spalte gleich sind. Also z.B. 0/0, 1/1, 2/2, 3/3 usw.


Umsetzung in Window_Selectable

Kommen wir zurück auf das eigentliche Thema. Window_Selectable verwendet denselben Mechanismus, mit dem wir unsere Array2D Klasse implementiert haben. Sie besitzt eine Instanzvariable @column_max, welche die Anzahl der Tabellenspalten enthält. Im Gegensatz zu unserer Array2D Klasse besitzt sie keine Instanzvariable mit der Zeilenanzahl. Stattdessen hat sie eine Variable @item_max, welche die Gesamtanzahl aller Tabelleneinträge enthält. Kleine Übung: Wenn wir Spaltenanzahl und Gesamtanzahl wissen, wie viele Zeilen haben wir dann? Klar: @item_max / @column_max viele Zeilen. Denn @column_max * zeilenzahl muss immer der Anzahl der enthaltenen Tabelleneinträge entsprechen. Warum verwendet Window_Selectable die variable @item_max statt eine Variable @row_max (Zeilenanzahl)? Denken wir an das Item-Menü im Maker. Es hat zwei Spalten. Angenommen wir haben drei Items. Dann dürfte es nur drei Menüeinträge geben. Der Menüeintrag unten rechts wäre ja dann leer. Und das ist auch der Fall. Wir brauchen es ja nur einmal testweise im Maker ausprobieren. Wüssen wir nur das unser Menü zwei Spalten und zwei Zeilen hat, dann wüssten wir dennoch nicht die genaue Anzahl der Menüeinträge, denn es könnten ja drei oder vier Menüeinträge sein. Window_Selectable erlaubt das Definieren von Tabellen, deren letzte Zeile nicht komplett gefüllt sein muss. Aus diesem Grund ist es wichtig, dass wir nicht nur die Anzahl der Spalten sondern auch die Anzahl der Items speichern. Da die Anzahl der Zeilen sich aus diesen Variablen berechnen lässt, definiert Window_Selectable für die Zeilenzahl eine Methode:

def row_max
  # Compute rows from number of items and columns
  return (@item_max + @column_max - 1) / @column_max
end

Ich hatte vorhin gesagt wir können aus einem Index die Zeilenzahl errechnen, in dem wir ihn durch die Anzahl der Spalten dividieren. Hier müssen wir vorsichtig sein und zwischen Index und Anzahl unterscheiden. Ein Array mit einem Element hat die Länge 1. Der Index des ersten Elements ist aber 0. Hier ist es genauso. @item_max und @column_max sind Längenangaben. Das letzte Item im Menü hat also einen Index (@item_max - 1). Darum subtrahiert die Methode von @item_max auch die 1 ab. Warum wird dann noch einmal mit @column_max addiert? Aus genau dem selben Grund. Wir wollen am Ende ja nicht den Index der letzten Zeile haben, sondern die Anzahl der Zeilen. Darum müssen wir am Ende auf den Zeilenindex noch 1 draufaddieren. Da wir durch @column_max teilen ist es egal, ob wir ganz am Ende 1 addieren oder ob wir vor der Division um @column_max addieren. Folgendes wäre also auch möglich gewesen:

def row_max
  (@item_max - 1) / @column_max + 1
end

Diese Form der Methodendefinition würde genau das gleiche machen (ich hoffe das kann jeder nachvollziehen).


Damit unser Window_Selectable weiß wo der Cursor gerade hinzeigt, hat es eine Variable @index. Diese wird in der initialize Methode auf -1 gesetzt. Ein Index von -1 bedeutet in der Window_Selectable Klasse, dass das Menü deaktiviert ist. Es wird kein Cursor angezeigt und das Menü reagiert auch nicht auf Tastendruck. Das hätte man auch anders implementieren können, z.B. über eine Variable @is_active. Aber die Macher haben sich eben dafür entschieden den @index bei inaktiven Menüs auf -1 zu setzen.

Das Menü definiert einen Getter für @index. Aber wenn wir im Script weiter runter scrollen entdecken wir auch, dass die Klasse eine Setter-Methode für @index implementiert. Diese sieht folgendermaßen aus:

def index=(index)
  @index = index
  # Update Help Text (update_help is defined by the subclasses)
  if self.active and @help_window != nil
    update_help
  end
  # Update cursor rectangle
  update_cursor_rect
end

Wir sehen: die Setter-Methode macht noch mehr als nur die Instanzvariable neu zu belegen. Sie ruft eine Methode update_help(), falls die Variable @help_window belegt ist, und eine Methode update_cursor_rect() auf. update_help() aktualisiert den Inhalt des Hilfefensters, update_cursor_rect() positioniert den Cursor des Menüs an die neue Position. Wenn wir in der Klasse nach der update_help Methode suchen, werden wir feststellen, dass sie gar nicht definiert ist. Wie denn auch? Window_Selectable ist ja nur eine allgemeine Oberklasse für tabellarische Menüs. Sie weiß gar nicht wie sie ein Hilfefenster updaten soll. Sie weiß ja auch gar nicht, was in einem Hilfefenster drinne stehen sollte. Erinnern wir uns an unser Charakterauswahlmenü zurück. Auch dort hatten wir eine Oberklasse Sprite_Auswahl, die viele Methoden wie box_grafik, box_beschriftung etc. definiert hat. In diesen Methoden haben wir dann immer einen "leeren Wert" zurückgegeben. Denn unser Sprite_Auswahl wusste ja gar nicht, wie es die Boxen zeichnen und beschriften soll. Erst die Klasse Sprite_CharakterAuswahl, die ein konkretes Menü darstellt, wusste das und überschrieb diese Methoden. Window_Selectable handhabt es ähnlich. Sie hätte auch eine leere update_help() Methode definieren können (was meiner Meinung nach sauberer wäre), aber es bleibt dabei, dass es den Subklassen von Window_Selectable überlassen ist, diese Methode zu definieren.


Die Window_Selectable Klasse ist ziemlich umfangreich und es ist eine gute Übung, sich die einzelnen Methoden einmal anzuschauen und sie nachzuvollziehen. Dennoch möchte ich mich nicht das ganze Kapitel nur damit aufhalten, den Source-Code dieser Klasse zu beschreiben. Ich möchte aber noch auf ein paar Besonderheiten aufmerksam machen. Zum einen wäre da die Methode Window_Selectable#top_row.

def top_row
  # Divide y-coordinate of window contents transfer origin by 1 row
  # height of 32
  return self.oy / 32
end

Sie gibt den Index der obersten Zeile zurück. Das dürfte im ersten Moment überraschen. Ist denn der Index der obersten Zeile nicht immer 0? Nein, nicht unbedingt. Denn Window_Selectable ist ein scrollbares Menü, wie wir ja schon in den vorherigen Kapiteln feststellen durften. Das Scrollen funktioniert über das oy Attribut. Um also die oberste Zeile zu ermitteln, müssen wir anschauen wie stark das Menü bereits nach unten gescrollt wurde (das steht ja in oy drin) und diesen Wert durch die Höhe der Menüeinträge dividieren. Hier kommt die zweite Überraschung. Im Source-Code wird einfach 32 hingeschrieben. Ist denn die Höhe der Menüeinträge nicht konfigurierbar? Nein, unsinnigerweise nicht! Da alle Standard-Menüs Menüeinträge mit 32 Pixel Höhe verwenden, haben es die Programmierer der RGSS nicht für nötig gefunden die Menühöhe irgendwie veränderbar zu machen. Wir sehen: Window_Selectable bietet uns zwar viel Funktionalität an, es ist aber dennoch sehr eingeschränkt. Unser Heldenauswahl-Menü hätten wir mit Window_Selectable nicht umsetzen können, da unsere Heldengrafiken größer als 32 Pixel waren. Gibt es eine ähnliche Einschränkung auch für die Breite? Nein, die Breite der Menüeinträge ist variabel und berechnet sich aus der Breite des contents-Bitmap (was wiederum Breite des Windows minus 32 ist) dividiert durch die Anzahl der Spalten. Allerdings lässt sich das Menü nicht nach links oder rechts scrollen. Window_Selectable implementiert nur das Scrollen nach unten und oben.

Die zweite Beobachtung, die wir noch machen wollen: Es gibt keine refresh Methode in der Klasse. Auch werden wir nirgends eine Methode finden, die irgendetwas zeichnet. Auch hier ist es wieder so: Das Zeichnen des Menüinhalts ist Sache der Subklassen. Die Window_Selectable Klasse ist nur für da Anzeigen und Bewegen des Cursors zuständig.


Wie funktioniert also Window_Selectable? Fassen wir zusammen: Die @index Variable zeigt auf den gerade ausgewählten Menüpunkt. Ist der Wert negativ, so ist das Menü inaktiv. Wir müssen also, wollen wir eine Subklasse von Window_Selectable schreiben, daran denken den @index auf einen positiven Wert zu setzen. Wollen wir den Index des Menüs ändern, so sollten wir die Settermethode Window_Selectable#index= verwenden, denn sie sorgt dafür das der Cursor neu positioniert und das Hilfefenster geupdatet wird. Wollen wir für unser Menü ein Hilfefenster haben, so müssen wir erst einmal eine update_help Methode schreiben, die das Hilfefenster updatet. Außerdem müssen wir unserem Menü ein Hilfefenster zuweisen. Dies machen wir über die Settermethode Window_Selectable#help_window=.

Damit das Menü aktualisiert wird, müssen wir es jeden Frame die Window_Selectable#update Methode aufrufen. Dort werden die Tasteneingaben abgefragt. Mit den Pfeiltasten bewegen wir den Cursor durch das Menü. Mit den L/R Tasten (auf der Tastatur sind das Page Down und Page Up) kann man den Cursor mehrere Zeilen weit nach unten/oben springen lassen.

Die Enter-Taste wird nicht abgefragt. Erneut ist dies Angelegenheit der Subklassen, denn Window_Selectable weiß ja ohnehin nicht was beim Drücken der Enter-Taste geschehen soll.

Schreiben eines horizontalen Menüs

Als nächstes wollen wir eine eigene Subklasse von Window_Selectable schreiben. Wir fangen mit etwas einfachem an: Einem vertikalen Auswahlmenü. Es soll genauso aufgebaut sein wie Window_Command, nur eben horizontal sein. Was müssen wir überschreiben? Ein Hilfsfenster brauchen wir nicht, die update Methode müssen wir ebenfalls nicht überschreiben, denn das Abfragen der Enter-Taste kann derjenige machen, der das Menü letztlich verwendet. Wir müssen lediglich eine eigene initialize Methode schreiben, in der wir @index, @column_max und @item_max setzen, sowie eine refresh-Methode, die das Menü zeichnet.

class Window_Command_Horizontal < Window_Selectable
  def initialize(commands)
    @commands = commands
    @entry_width = compute_entry_width()
    super(0, 0, @entry_width * @commands.size + 32, 64)
    self.contents = Bitmap.new(width-32, height-32)
    @item_max = @commands.size
    @column_max = @commands.size
    @index = 0
    refresh
  end
 
  def refresh
    contents.clear
    contents.font.color = normal_color()
    @commands.each_index do |index|
      draw_item(index)
    end
  end
 
  def draw_item(index)
    contents.draw_text(text_rect(index), @commands[index].to_s, 1)
  end
 
  def disable_item(index)
    contents.fill_rect(text_rect(index), Color.new(0, 0, 0, 0))
    contents.font.color = disabled_color()
    draw_item(index)
  end
 
  def text_rect(index)
    w = width/@commands.size
    Rect.new(index * w, 0, w-32, 32)
  end
 
  def compute_entry_width
    bitmap = Bitmap.new(1, 1)
    widths = @commands.map do |command|
      bitmap.text_size(command.to_s).width
    end
    widths.max + 32
  end
 
end

TODO: Fortführen und Code kommentieren