Lektion 23 - Oberflächenbewegung

Erstellt von Frithjof Tue, 30 Sep 2008 21:58:00 GMT

Lektion 23 – Oberflächenbewegung

In dieser Lektion erweckst du den LKW zum Leben. Er wird deiner Tastatur und deiner Maus gehorchen und sich auf der Oberfläche von FXRuby vorantasten. Schritt für Schritt wirst du in den kommenden Lektionen dann seine Bewegungen verbessern, sodass es am Ende hoffentlich nicht übertrieben sein wird zu sagen, er fährt!

Bevor wir neue Dinge in das Programm einbauen, strukturieren wir es etwas um. Denn vielleicht möchtest du selbst ein anderes Fahrzeug oder ganz anderes Objekt zeichnen. Dafür ist es besser, das Zeichnen selbst aus der Klasse RubykidsMainWindow auszulagern.

Das Zeichnen des LKW übernimmt die neue Klasse LKW.


class LKW
  def initialize(col_background = FXColor::White)
    @col_background = col_background
    @col_dach       = FXColor::Black
    @col_karosse    = FXColor::Blue
    @col_fenster    = FXColor::DarkBlue
    @col_reifen     = FXColor::Black
    @col_felgen     = FXColor::White
    @col_ruecklicht = FXColor::Red
    @col_blinker    = FXColor::DarkOrange
  end

  def draw(dc, pos = FXPoint.new(0,0))
    return if dc.nil?
    # Einen LKW malen
    # Karosserie
    dc.foreground = @col_karosse
    dc.fillRectangle(pos.x, pos.y,    30, 10) # Oberes Teil
    dc.fillRectangle(pos.x, pos.y+10, 40, 10) # Unteres Teil

    # Dach
    dc.foreground = @col_dach
    dc.drawLine(pos.x, pos.y, pos.x+30, pos.y)

    # Seitenfenster
    dc.foreground = @col_fenster
    dc.fillRectangle(pos.x+2,  pos.y+2, 15, 6) # Hinten
    dc.fillRectangle(pos.x+18, pos.y+2, 10, 6) # Vorne

    # Mit Hintergrundfarbe einen Bereich für die 
    # Räder aus der Karosse schneiden
    dc.foreground = @col_background
    dc.fillCircle(pos.x+8,  pos.y+20, 5) # Hinten
    dc.fillCircle(pos.x+32, pos.y+20, 5) # Vorne

    # Reifen anbringen
    dc.foreground = @col_reifen
    dc.fillCircle(pos.x+8,  pos.y+20, 4) # Hinten
    dc.fillCircle(pos.x+32, pos.y+20, 4) # Vorne

    # Felgen drüber malen
    dc.foreground = @col_felgen
    dc.fillCircle(pos.x+8,  pos.y+20, 2) # Hinten
    dc.fillCircle(pos.x+32, pos.y+20, 2) # Vorne

    # Rücklicht
    dc.foreground = @col_ruecklicht
    dc.fillRectangle(pos.x-1, pos.y+10, 2, 6)

    # Blinklicht vorne
    dc.foreground = @col_blinker
    dc.fillRectangle(pos.x+37, pos.y+12, 3, 2)
  end
end

Die Klasse hat neben dem Constructor initialize, der von außen nur die Hintergrundfarbe mitgeteilt bekommt und die restlichen Fahrzeugfarben festlegt noch die Methode draw. Sie wird mit dem Device Context dc und der Position aufgerufen an die in den dc gezeichnet werden soll.

Die absoluten Werte für die Positionen der geometrischen Objekte (Rechtecke, Kreise, Linie) sind nun relativ zu der von außen gewünschten Position angepasst.

Damit muss das RubykidsMainWindow auch etwas angepasst werden.


class RubykidsMainWindow < FXMainWindow
  def initialize(app)
    super(app, "Rubykids.de", :opts => DECOR_ALL, :width => 400, :height => 300)
    #...

    # Variablen für die Position des LKW
    @current_pos    = FXPoint.new(30, 20)
    @move_distance  = FXPoint.new(18, 18)
    @lkw = LKW.new

    #...
  end

  def onLeinwandRepaint(sender, sel, event)
    FXDCWindow.new(@leinwand, event) do |dc|
      dc.foreground = FXRGB(255, 255, 255)
      dc.fillRectangle(0, 0, @leinwand.width, @leinwand.height)

      @lkw.draw(dc, @current_pos)
    end
  end

end

Es kommen ein paar neue Instanzvariablen hinzu, current_pos merkt sich die Stelle, an der der LKW gerade ist (d.h. seine linke obere Ecke), move_distance legt fest, in welchen Schritten sich der LKW später in X-Richtung bzw. in Y-Richtung bewegen soll, und natürlich lkw selbst das Objekt, dass den LKW darstellt.

Für Punkte gibt es in FXRuby eine einfache Klasse FXPoint. Dem Konstruktor gibt man zwei Werte mit, den X-Wert und den Y-Wert. Auf beide Werte kann man anschließend mit den Methoden x bzw. y zugreifen.

Das war es auch schon an Vorarbeit. Jetzt bewegen wir ihn.

Maus- und Tastaturinteraktionen mit FXRuby

Der LKW soll
  • bei Klick mit der linken Maustaste vorwärts (also nach rechts),
  • bei Klick mit der rechten Maustaste rückwärts (also nach links),
  • beim Drehen am Mausrad vor- bzw. rückwärts fahren.

Wird eine Maustaste betätigt, wird eine Nachricht von der FXRuby Applikation ausgelöst. Diese Nachricht können wir abfangen und in einer Methode verarbeiten.

Das Abfangen der Nachricht geht wie in der letzten Lektion gesehen mit einem connect:


class RubykidsMainWindow < FXMainWindow
  def initialize(app)
    super(app, "Rubykids.de", :opts => DECOR_ALL, :width => 400, :height => 300)
    #...
    @leinwand.connect(SEL_LEFTBUTTONPRESS,  method(:onMouseDown))
    @leinwand.connect(SEL_RIGHTBUTTONPRESS, method(:onMouseDown))
    @leinwand.connect(SEL_MOUSEWHEEL,       method(:onMouseWheel))
    #...
  end
end

Damit werden die beiden Nachrichten Linksklick und Rechtsklick mit einer gemeinsamen Methode onMouseDown verbunden, während wir die Nachricht über das Betätigen des Mausrades an eine separate Methode onMouseWheel knüpfen.

Die beiden Methoden definieren wir als Methoden der Klasse RubykidsMainWindow wie folgt:


def onMouseDown(sender, sel, event)
  if event.click_button == 1
    # Linke Maustaste => vorwärts
    self.move_forward
  elsif event.click_button == 3
    # Rechte Maustaste => rückwärts
    self.move_backward
  end
end  

def onMouseWheel(sender, sel, event)
  if event.code > 0
    # Vorwärts
    self.move_forward
  elsif event.code < 0
    # Rückwärts
    self.move_backward
  end
end

Wie wir genau die Vorwärts und Rückwärtsbewegung durchführen sehen wir später. Schauen wir uns zunächst an, wie wir dasselbe und noch zwei weitere Bewegungsrichtungen mit der Tastatur verknüpfen können.

Zuerst wieder die Nachricht für einen Tastendruck auf die Leinwand mit einer Methode onKeyPressed verknüpfen.


class RubykidsMainWindow < FXMainWindow
  KEY_ARROW_LEFT  = 65361
  KEY_ARROW_UP    = 65362
  KEY_ARROW_RIGHT = 65363
  KEY_ARROW_DOWN  = 65364

  def initialize(app)
    super(app, "Rubykids.de", :opts => DECOR_ALL, :width => 400, :height => 300)
    #...
    @leinwand.connect(SEL_KEYPRESS, method(:onKeyPressed))
    #...
  end
end

Gleichzeitig führen wir ein paar Klassenkonstanten in der Klasse RubykidsMainWindow ein, die den Tastencode für die vier Cursortasten tragen. Diese Konstanten verwenden wir dann in der Methode onKeyPressed zum Erkennen, welche Taste denn genau gedrückt wurde.


def onKeyPressed(sender, sel, event)
  if event.code == KEY_ARROW_LEFT
    # Rückwärts
    self.move_backward
  elsif event.code == KEY_ARROW_RIGHT
    # Vorwärts
    self.move_forward
  elsif event.code == KEY_ARROW_UP
    # Nach oben
    self.move_up
  elsif event.code == KEY_ARROW_DOWN
    # Nach unten
    self.move_down
  end
end

Fertig! Jetzt müssen wir uns wie bereits erwähnt Gedanken machen, wie genau die Bewegungen auf der Leinwandoberfläche vor sich gehen sollen. Vor allem müssen wir klären, was passieren soll, wenn der LKW einen der vier Ränder des Leinwandbereiches erreicht oder sogar darüber hinaus fährt. Soll er in den unsichtbaren Bereich verschwinden? Oder sollen wir es verhindern, dass er dort weiterfährt?

Wir wollen es so gestalten, dass der LKW beliebig fahren kann. Sobald er aber auf einer Seite über den Rand herausfährt, erscheint er gleichzeitig wieder auf der gegenüberliegenden Seite. Das ist so als wären die gegenüberliegenden Ränder hinter dem Fenster herum miteinander verbunden. Die Leinwand wird somit zu einem Ausschnitt auf einem Torus auf dem unser LKW nicht verloren gehen kann.

Um das zu erreichen, sorgen wir zunächst dafür, dass sich die aktuelle Position des LKW immer innerhalb der Leinwand befindet. Sobald die X- oder Y-Koordinate kleiner Null oder größer als die Höhe oder Breite der Leinwand geraten, werden wir sie wieder auf einen Wert zwischen Null und der Höhe bzw. Breite der Leinwand bringen. Das geht am einfachsten mit dem Modulo Operator, den du bereits in Lektion 12 – Tic-Tac-Toe, Eingabe von Zügen kennengelernt hast.

Die vier Bewegungsrichtungen können wir somit wie folgt neu bestimmen:


def move_forward
  @current_pos.x = (@current_pos.x + @move_distance.x) % @leinwand.width
  @current_pos.y = (@current_pos.y                   ) % @leinwand.height
  @leinwand.update
end

def move_backward
  @current_pos.x = (@current_pos.x - @move_distance.x) % @leinwand.width
  @current_pos.y = (@current_pos.y                   ) % @leinwand.height
  @leinwand.update
end

def move_up
  @current_pos.x = (@current_pos.x                   ) % @leinwand.width
  @current_pos.y = (@current_pos.y - @move_distance.y) % @leinwand.height
  @leinwand.update
end

def move_down
  @current_pos.x = (@current_pos.x                   ) % @leinwand.width
  @current_pos.y = (@current_pos.y + @move_distance.y) % @leinwand.height
  @leinwand.update
end

Bei der Vorwärtsbewegung addieren wir zu der X-Richtung einen Distanzschritt hinzu, die Y-Richtung bleibt unverändert. Bei einer Rückwärtsbewegung ziehen wir den gleichen Distanzschritt von der X-Richtung ab, wobei die Y-Richtung erneut unverändert bleibt.

Für die Auf- und Abbewegung machen wir das gleiche, nun allerdings mit der Y-Richtung und die X-Richtung bleibt unverändert.

Am Ende der Addition oder Subtraktion des Distanzschrittes wird das Ergebnis der X- und Y-Richtung nur noch mit dem Modulooperator auf die Breite bzw. Höhe der Leinwand gestutzt. Somit erhalten wir immer eine neue Position, bei der die X-Koordinate größer oder gleich Null aber immer kleiner als die Leinwandbreite ist und die Y-Koordinate ebenfalls größer oder gleich Null aber immer kleiner als die Höhe der Leinwand ist.

Allerdings ist die Bewegung über die Ränder hinweg noch etwas unruhig. Der entscheidende Punkt des LKW ist die obere linke Ecke. Erst wenn sie am Rand aus der Leinwand wandern will, erscheint der LKW schlagartig auf der gegenüberliegenden Seite.

Bei der Illusion eines richtigen Torus würde man erwarten, dass der LKW auf der gegenüberliegenden Seite sofort beginnt zu erscheinen, in dem Maße wie er am Rand verschwindet. Die Herausforderung dabei ist, dass wir feststellen müssten, wieviel vom LKW schon über den Rand hinausragt, um diesen Teil dann auf der gegenüberliegenden Seite zu zeichnen.

Aber es geht einfacher. Schauen wir uns folgende Skizze an

Die Illusion eines Torus können wir am einfachsten erreichen, wenn wir die Leinwand selbst nochmals an ihren Rändern anfügen. Somit erhalten wir insgesamt acht zusätzliche Flächen um die zentrale Leinwand herum. Die obige Skizze verrät schon, was wir dann nur noch machen müssen: wir müssen den LKW nicht nur an der aktuellen Position zeichnen, sondern ihn auch noch um die Höhe oder Breite der Leinwand oder beides zugleich verschoben zeichnen. Der LKW wird somit insgesamt vier mal gezeichnet.

Das erreichen wir schnell durch eine kleine Anpassung der Methode onLeinwandRepaint:


def onLeinwandRepaint(sender, sel, event)
  FXDCWindow.new(@leinwand, event) do |dc|
    dc.foreground = @col_background
    dc.fillRectangle(0, 0, @leinwand.width, @leinwand.height)

    pos = FXPoint.new(@current_pos.x, @current_pos.y)
    [
      [0,               0],                # Aktuelle Position des LKW
      [@leinwand.width, 0],                # Verschoben nach links
      [0,               @leinwand.height], # Verschoben nach oben
      [@leinwand.width, @leinwand.height]  # Verschoben nach links oben
    ].each do |off|
      pos.x = @current_pos.x - off[0]
      pos.y = @current_pos.y - off[1]

      @lkw.draw(dc, pos)
    end
  end
end

In einer Liste legen wir zunächst die vier Offsets, d.h. die Entfernungen von der aktuellen Position, fest und subtrahieren diese Offsets anschließend von der aktuellen Position und zeichnen den LKW an die dadurch berechnete neue Position.

Die Übergänge an den Rändern werden damit etwas sanfter und der LKW scheint wirklich aus dem einen Rand heraus direkt auf der gegenüberliegenden Seite wieder in die Leinwand hinein zu fahren.

Peter und Livia

Peter: Viel realistischer wäre es, wenn der LKW am Rand stehenbleiben würde.

Livia: Das klingt zwar leichter, ist aber etwas schwieriger zu implementieren. Denn dazu musst du genau erkennen können, ob der LKW an irgendeiner Stelle auch nur einen Pixel über den Rand hinausragen würde. Na dann, viel Spaß beim implementieren.