Billard Pro

Aus toolbox_interaktion
Wechseln zu: Navigation, Suche

Für das Multimedia-Design-Projekt der Informatik-Disziplin Interaktion wurde im Sommersemester 2006 ein interaktives System realisiert, das durch Echtzeit-Videobild-Analyse die gestische Steuerung einer Multimediaapplikation ermöglicht. Hierbei handelt es sich um ein virtuelles Billardspiel, das vertikal auf einen Tisch projiziert wird und dessen Kugeln mit einem Queue vom Spieler gelenkt werden. Die Bewegung bzw. Position des Queues wird dabei von dem Videobild einer Infrarotkamera erkannt und zur Steuerung des Programms herangezogen.

Einleitung

Für die Realisierung dieses Systems war es wichtig, drei Komponenten fertig zu stellen.
Zum einen musste eine Konstruktion gebaut werden, die einen Beamer und eine Kamera vertikal über einem Tisch trägt. Des weiteren galt es die Koordinaten der Position des Akteurs aus dem Videobild zu extrahieren und letztendlich galt es die Anwendung, die mit den zuvor gewonnenen Koordinaten arbeitet, zu gestalten und zu programmieren.

Zentrales Wesensmerkmal bildet die Software, die sich aus dem für die Bildverarbeitung zuständigen, obligatorischen Programm EyesWeb des InfoMus Lab der Universität Genua und Macromedia Flash, welches die Laufzeitumgebung für das Billardspiel darstellte, zusammensetzt. Zur Realisierung von auf unser Projekt angepassten Funktionalitäten im Programm EyesWeb bedurften wir zusätzlich der C-/C++-Bibliothek OpenCV mit der komplexere, über das ursprüngliche Potential von EyesWeb hinausreichende Bildverarbeitungsmöglichkeiten in EyesWeb eingearbeitet werden konnten.
Als Entwicklungsumgebung für die Erweiterung von EyesWeb kam Microsoft Visual C++ 6.0 zum Einsatz. Des weiteren wurde für die Oberflächengestaltung Cinema4D verwendet.

Projektor-Kamera-Aufhängung

Konzeption

Verschiebung der Bildachse des Projektors
Da ein Beamer ein nach oben versetztes Bild projiziert, entspricht die Bildmittelachse nicht der Projektormittelachse. Um einer Trapezverzerrung des Videobildes konstruktionstechnisch entgegen zu wirken, kann die Kamera und der Projektor so angebracht werden, dass die Mittelachse des Projektorbildes der Mittelachse des Kameraobjektivs entspricht.

Da sich bei einer Änderung der Projektionsentfernung (also bei einer Änderung der Höhe der Projektor-Kamera-Konstruktrion) die Mittelachsen verschieben, sollten Kamera und Projektor entsprechend verschiebbar an der Aufhängung angebracht sein.

Im übrigen ergibt sich die Problematik, dass die Brennweiten von Kamera und Projektor unterschiedlich sind.

Abschließend stellten sich folgende Anforderungen an die Konstruktion:

  • Raumunabhängigkeit/Höhenverstellbarkeit
  • Projektor-/Kameraverstellbarkeit (horizontal)
  • Portabilität
  • Stabilität

Erste Ideen waren ein Pavillonmodell, bei dem ein gewöhnlicher Pavillon als Aufhängung von Kamera und Beamer dienen sollte und ein Kranmodell, in Form einer selbst anzufertigenden Metall-Konstruktion. Letzten Endes lief die Entscheidung jedoch auf eine Torkonstruktion hinaus. Als kostengünstiges Baumaterial boten sich Abflussrohre aus dem Baumarkt an. Jedoch existiert bereits ein industriell gefertigtes Regalsystem namens "Stolmen", welches den Anforderungen weitestgehend entspricht.

Modell "Kran"
Modell "Abfluss"
Modell "Stolmen"

Dieses vom Möbelhaus IKEA vertriebene Aufhängungssystem überzeugte durch folgende Wesensmerkmale:

  • sehr variables und zugleich stabiles Teleskopstangen-System
  • fertige Befestigungskomponenten sind enthalten
  • Regalbleche können als Projektor-Kamera-Halterung verwendet werden
  • verminderter Platzbedarf und gute Portabilität
  • schnelle Installierung möglich
  • preisgünstige und ästhetische Variante

 

Konstruktion

Querstangenverbindung
Es wurde fast ausschließlich auf den Pool der "Stolmen"-Produktfamilie zurückgegriffen, da dieser alle benötigten Bauteile enthielt. Neben den zwei von 210 bis 330 cm ausziehbaren Teleskopstangen als Standpfosten, wurde auch eine jener Stangen für den horizontalen Balken, an dem Projektor und Kamera aufgehängt werden sollten, verwendet. Verbunden sind alle Pfosten mit Schellen des "Stolmen"-Systems, bei denen die ursprünglichen Schrauben durch längere ersetzt wurden. So konnte mit zwei Schellen ein 90°-Winkel gebildet werden, um die vertikalen Stangen mit der horizontalen zu verbinden. Des weiteren eigneten sich die Schellen natürlich auch für die Anbringung der Montagebleche für Projektor und Kamera. Auch diese Bleche stammen aus dem "Stolmen"-System und finden dort eigentlich als Schuhablagen Verwendung. Diese tragenden Bleche wurden mit einer Flex zugeschnitten und so mit den "Stolmen"-Schellen an der Querstange befestigt. Um dem Gewicht des Projektors entgegenzuwirken, wurde ein zweites Metallblech über einen Winkel mit dem eigentlichen Trägerblech verbunden.
Aufhebung der Blechverbiegung

Kamerabildkalibrierung

Kamerazentrierung

Zur exakten Positionserkennung besteht die Notwendigkeit, ein Aufnahmebild möglichst ohne jegliche Entzerrung zur Verarbeitung heranziehen zu können. Ein in Eyes-Web realisierter Patch sollte dazu dienen, die Kamera möglichst exakt auf die Projektion zu zentrieren, um auf diese Weise rein konstruktionstechnisch eine Trapezverzerrung der aufgenommenen Projektion im Kamerabild zu vermeiden.

EyesWeb-Patch zur Kamerazentrierung
Der Patch verfügt über eine Ausgabe der Distanz vom Mittelpunkt des Kamerabildes zum Mittelpunkt der Projektion. Des weiteren gibt er auch den reinen x- und y-Versatz aus. Ferner sollte der Patch zur Erleichterung der Kameraeinrichtung an der Aufhängungskonstruktion ohne Einblick auf den Computerbildschirm eigentlich noch eine dynamische Soundausgabe besitzen, die je nach Näherung der beiden Mittelpunkte einen Ton länger oder kürzer andauernd und mit entsprechenden Pausen ausgibt; bei einer Deckung der beiden Mittelpunkte soll der Ton konstant abgespielt werden.

Bis zur Ausgabe der Distanz und der Versätze ist der Patch noch sehr einfach und überschaubar. Die Problematiken des Patches sind erst nach diesem wichtigsten Punkt zu finden; betreffen also die Ausgabe des Signaltons. Aber von vorne: Als erster Schritt wird direkt eine Binarisierung des eingehenden Kamerabildes durchgeführt. Es wird also als Voraussetzung an das Kamerabild der Anspruch eines Hell-Dunkel-Kontrast gestellt. Dies wird dadurch erreicht, dass man auf dem Projektor eine komplett weiße Fläche ausgibt (etwa, indem man den Projektor als erweiterten Desktop nutzt und den Desktop-Hintergrund weiß färbt). Um schon erste mögliche Störungseinflüsse im Bild zu entschärfen, wird vor der eigentlichen Binarisierung eine Unschärfe-Operation auf das Kamerabild angewandt. Nach der anhand eines variablen Schwellwertes, der so angepasst wird, dass nur die helle Projektion in weiße Bildpunkte umgesetzt werden, durchgeführten Binarisierung wird weiterhin durch den Einsatz von Median, Dilatation und Erosion versucht, das nun vorhandene Binärbild von etwaigen Störungen zu befreien.
Anschließend wird um die weißen Pixel, die im optimalen Fall also lediglich die projizierte Fläche beschreiben, ein umschließendes Rechteck gesetzt beziehungsweise berechnet. Aus der Matrix, die die Koordinaten der linken oberen und der rechten unteren Ecke dieses Rechtecks enthält werden die Koordinaten des Rechteckmittelpunkts im Kamerabild berechnet. X- und y-Koordinate desselben werden schließlich wieder zu einer Matrix zusammengefügt. Parallel werden die Koordinaten des Kamerabildmittelpunkts aus den Kamerabildinformationen Breite und Höhe berechnet und ebenso zu einer Matrix zusammengefügt. Nun kann anhand der beiden vorliegenden Matrizen die Vektordistanz zwischen den zwei Mittelpunkten berechnet werden. Durch die einzelnen Skalarwerte können ebenso reiner x- und y-Versatz ermittelt werden.

Nun beginnt es kompliziert zu werden, was mehr oder weniger auf den vergeblichen Versuchen gründet, den Patch mit Soundausgabe korrekt zum Laufen zu bringen. Im vorliegenden Patch existieren zwei Möglichkeiten, wie der Signalton ausgegeben werden könnte oder sollte. Der einfachere ist die konstante Ausgabe des Signaltons bei einer Deckung der Mittelpunkte von Kamerabild und umschließendem Rechteck. Falls also die Vektordistanz ihren minimalen Wert 1 annimmt, wird ein OutputSwitch umgeschaltet, sodass ein konstanter Signalton ausgegeben wird. Parallel wird die Ausgabe der zweiten Tonausgabemöglichkeit durch einen weiteren OutputSwitch schon im Ansatz unterbunden. Diese weitere Möglichkeit der Signaltonausgabe wird immer dann abgerufen, wenn sich die Mittelpunkte von Kamerabild und umschließendem Rechteck eben nicht decken. In diesem Fall wird die Vektordistanz zuerst an eine Blockfolge weitergeleitet, die das häufig auftretende "Zittern" des umschließenden Rechtecks möglichst aufheben soll. Dieses "Zittern" entsteht aufgrund umspringender binarisierter Pixel, die sich im Graustufenbild des Kamerabildes nahe am Binarisierungsschwellwert befinden. Zur Linderung der Problematik wird in einer Konstanten ein temporärer Distanzwert festgehalten, der erst aktualisiert wird, sobald ein neuer Distanzwert, der um einen bestimmten Absolutwert vom zwischengespeicherten Distanzwert abweicht, die Blockfolge erreicht. Diese Prozedur sollte dazu dienen, durch zumindest in gewissem Maße gleichbleibendere Tonfolgen, kein allzu konfusionierendes Tonsignal zu erhalten beziehungsweise die Wahrnehmung des Tonsignals und den Schluss auf die Größe der Mittelpunktdistanz zu erleichtern. Von der Blockfolge wird der aktuelle Distanztemporärwert an die eigentliche Audiosignalaufbereitung weitergeleitet. Zuerst wird durch eine Wurzelfunktion der Wertebereich verkleinert, sodass auch kurze Distanzen später wahrnehmbar und erkennbar sein sollten. Um die Dauer (T) in Sekunden der Ausgabe des Signaltones zu einem bestimmten Zeitpunkt zu erhalten, wird noch mit dem Faktor 10 multipliziert, da ansonsten die Tonintervalle generell zu kurz wären. Danach wird gemäß f = 1/T die Frequenz an eine Rechtecksfunktion mit dem Wertebereich [-1; 1] weitergegeben. Sofern diese Rechtecksfunktion den Wert 1 annimmt, soll ein generierter Signalton an die Tonausgabe weitergeleitet werden. Aufgrund der errechneten, aktuellen Frequenz gestaltet sich also die Ausgabedauer des aktuellen Signaltons und der ebensolangen Pause.

Soweit zur Theorie; in der Praxis weigert sich der Audioteil ein realistisches Signal wiederzugeben und bringt nicht mehr als ein Wirrwarr von Ton- und Pausenintervallen zu Stande. Als Grund wurde vor allem der Tonausgabeblock lokalisiert, da der vorgelagerte OutputSwitch sich zumindest bei etwas größeren Distanzen rein optisch gemäß dem Intervall korrekt verhält. Auch die Ausgabe des konstanten Tons bei Deckung der Mittelpunkte funktioniert, also fällt an sich auch der Tongenerator als mögliche Fehlerursache weg. Nun hätte man natürlich einfach auf den Audioteil des Patches verzichten können, aber aufgrund der damit verbrachten Zeit, wird eine Erwähnung hier dennoch für gerechtfertigt gehalten. Im Endeffekt bei Verwendung des Patches bleibt aber jedenfalls anzuraten, die Audioausgabe des Computers abzuschalten. Abgesehen davon wurde bei der Ausrichtung der Kamera in der Praxis das Augenmaß dem Patch vorgezogen.

Aufhebung der Kameraobjektivverzerrung

Die aufgrund der Wölbung der Linsen im Objektiv hervorgerufene tonnenförmige Verzerrung im Kamerabild würde für einen gewissen Versatz bei extrahierten Koordinaten während der Mustererkennung sorgen, da die Soll-Aktionsfläche (die Projektion) somit in der Ist-Aktionsfläche (dem Kamerabild) verzerrt ist. Aus dem Kamerabild extrahierte Koordinaten würden sich also nicht mit den realen Koordinaten in der Projektion decken.

Bereits recht früh im Projektverlauf begann man sich mit der Theorie der Objektivverzerrung auseinanderzusetzen und Lösungsansätze zu entwickeln. Die tragende Rolle spielte hierbei die Internetrecherche, wodurch Seiten gefunden wurden, die die zugrundeliegenden Formeln aufbereiten beziehungsweise selbst bereits Lösungsstrategien oder gar fertig Programme bereithalten. Da viele Inhalte nur behäbig zugänglich waren, begnügte man sich zu aller erst mit Fundamentalem; nämlich der Entwicklung eines EyesWeb-Patches mit Hilfe der in EyesWeb zur Verfügung stehenden Blöcke. Das Prinzip sah eine Kopplung von stufenweiser Entzerrung und Musterkennung vor. Als Basis sollte die allgemeine Verzerrungsformel herhalten:
Rn = a + bRa + cRa2 + dRa3 + eRa4; beziehungsweise eine vereinfachte Abwandlung davon: Rn = Ra + kRa3
(jeweils mit Rn als der Entfernung eines Punkts zum Zentrum der Verzerrung im unverzerrten Bild und Ra im verzerrten Bild; sowie den Konstanten a, b, c, d, e beziehungsweise k);
wobei die Formeln letztendlich sowieso nicht zum Tragen kamen, da sich ein EyesWeb-Patch auf Basis der in EyesWeb verfügbaren Komponenten letzten Endes als nicht realisierbar herausstellte.

gescheiterter Versuch eines EyesWeb-Patches zur Aufhebung der Kameraobjektivverzerrung
Mit Hilfe einer konstant ansteigenden Funktion, werden die Pixel des Eingangsbildes in x-Richtung durchlaufen. Erreicht diese Funktion den Maximalwert in Form der Breite des Eingangsbildes, wird in die nächste Pixelzeile des Eingangsbildes gesprungen, um diese wiederum in x-Richtung zu durchlaufen. Auf den jeweils aktuell betrachteten Pixel, der sogleich aus dem Ausgangsbild extrahiert wird, soll sodann die Entzerrungsformel angewendet werden (im Patch soweit nicht korrekt implementiert). Da über diese Entzerrungsformel eine neue Distanz des Bildpunktes zum Zentrum der Verzerrung – in der Regel zumindest näherungsweise der Bildmittelpunkt – berechnet wird, wird noch mit Hilfe des Aufbaus eines virtuellen rechtwinkligen Dreiecks und der dort geltenden Gesetzmäßigkeiten der Winkel des Vektors vom Mittelpunkt zum aktuell betrachteten Pixel berechnet. Anschließend muss für eine korrekte Neusetzung des Pixels noch unterschieden werden, in welchem Quadrant sich der aktuelle Bildpunkt befindet. Schließlich wird der Pixel auf dem praktisch verkürzten Vektor zu dessen Ursprungsposition neu auf das Ausgangsbild gesetzt. Leider ist es nun allerdings nicht so, dass der neu gesetzte Pixel etwa durch Referenzierung oder Ähnliches festgehalten werden kann. Es wird also immer nur jeweils der Pixel mit den aktuellen Koordinaten versetzt. Springen die Koordinaten weiter, befindet sich der Pixel wieder nur an seinem Herkunftsort. Ferner ist es auch nicht möglich, alle Bildpunkte in einer Geschwindigkeit zu durchlaufen, sodass alle Pixel eines Bildes neu gesetzt werden bevor ein neues Bild der Kamera eintrifft. Hier werden schlichtweg die Grenzen von EyesWeb überschritten.

Die Weiterentwicklung dieses Patches erübrigte sich also. Geplant war, dass die Konstanten der Entzerrungsformel stufenweise erhöht werden; solange, bis mittels Mustererkennung etwa ein projiziertes Rechteck auch gerade Linien aufgewiesen hätte. Die gefundenen Konstanten hätte man nach Ende dieses Kalibrierungsvorgangs für die permanente Entzerrung des Eingangsbildes heranziehen können.

Wie auch immer, es führte nun kein Weg daran vorbei, einen eigenen EyesWeb-Block zu programmieren. Erste Wahl hierfür bildete Intels OpenCV-Bibliothek, die bereits geeignete Funktionen für die Kalibrierung von Kameras und die Entzerrung von Bildern bereit hält. Es brauchte allerdings eine gewisse Anlaufzeit, bis alle Voraussetzungen für eine eigene Programmierarbeit erfüllt waren. Programmieren für die aktuelle EyesWeb-Version 4.0.2.0 gestaltete sich bereits im Ansatz zu einem Umständlichkeitsdebakel. Aber auch die Programmierung für Version 3.3.0 bedurfte erst der Klärung einiger frustrierender Kleinigkeiten wie etwa die korrekte Einbindung der OpenCV-Bibliotheken oder die Möglichkeit nur mit Version 6 von Microsofts Visual C++ Zugang zu den EyesWeb-Programmierhilfen zu erhalten.

Nach Klärung aller Modalitäten war es aber dennoch irgendwann soweit, dass man mit der Programmierung beginnen konnte. Wie sich schon bald herausstellte war eine ausreichende Dokumentation zur OpenCV-Bibliothek nirgends abrufbar. Als einzige offizielle Hilfe boten sich die OpenCV beigefügten Datentyp- und Funktionsauflistungen, die aber nur knappe Erläuterungen geben und so für jemanden, der mit OpenCV noch keine Erfahrung gesammelt hat, eher einen kryptischen Eindruck machen. Auch die Abstimmung der letztendlich selbsterstellten Blöcke auf EyesWeb glich mitunter einer Tortur, da die Dokumentation hierfür bestenfalls als ungenügend bezeichnet werden kann. Der EyesWeb-Distribution beigefügte Programmierbeispiele ließen die wichtigen Vorgänge, beispielsweise die Ausgabe eines neu generierten Bildes, unbeachtet. Der EyesWeb Programmer's Guide warf fast mehr Fragen auf als er beantwortete. Einzige Hilfestellung bildete die Internetrecherche. In der Tat reichte sie letzten Endes gar über eine bloße Hilfestellung hinaus. Es handelt sich genauer um zwei Referenzen, die sich bei der Entwicklung eines EyesWeb-Blocks zur Aufhebung der Kameraobjektivverzerrung als unentbehrlich herausstellten. Zu allererst ist hierbei das Complex Systems & Cognition Laboratory der University of California San Diego, zu nennen. Hier ist ein ganzer Pool von Modulen, die sich mit der Umsetzung von OpenCV-Funktionalität beschäftigen, abgelegt; und das mitsamt kompletter Quelltexte. Zum Glück war dort auch eine Umsetzung der Objektiventzerrung zu finden. Diese beschäftigte sich mit der Abfolge der OpenCV-Version-5-Funktionen cvFindChessboard-Corners(), cvCalibrateCamera2(), cvInitUndistortMap() und cvRemap(). Die reine Funktionalität war hier also bereits vollständig erfasst und die Aufgabe bestand nun darin, den vorliegenden Quelltext für EyesWeb zu portieren. Dies gestaltete sich auch weitestgehend unproblematisch.
Ein letztes Problem tat sich allerdings noch auf. Da es wie schon erwähnt aufgrund mangelhafter Dokumentation nicht möglich war, für EyesWeb ein neues Bild auszugeben, sondern nur die Option bestand einen Block zu erstellen, der darauf basiert, dass das ausgehende Bild eine Durchschleifung des eingehenden ist, welches wiederum während der Ausführung des Blocks modifiziert werden kann, wurde im Quelltext des entstehenden EyesWeb-Blocks lediglich mit der Referenz auf das eingehende Bild gearbeitet. Das diese Vorgehensweise sich mit der OpenCV-Funktion cvRemap() nicht verträgt, erfuhr man auch nur dank der Internetrecherche. Auf der "Odeion project page", deren Autor sich laut seinem Blog offensichtlich einige Zeit lang mit der Entzerrung via OpenCV in Verbindung mit EyesWeb beschäftigt hatte, ist zu lesen, dass jene Funktion cvRemap() unterschiedliche Parameter für Quellbild und Zielbild, auf das die Entzerrung angewendet werden soll, benötigt, da ansonsten unbrauchbare Ergebnisse auftreten. Also galt es, ein temporäres Zielbild zu erzeugen und das Bildarray des Eingangsbildes mit den Daten des neuen Bildes zu überschreiben. Zu aller erst wurde die einfache Methode verwendet, das Eingangsbild mit cvCloneImage() zu duplizieren; man ließ auf den Klon die Entzerrung durchführen und referenzierte letztendlich auch nur den Zeiger des Eingangsbildes auf das entzerrte Bild. Wie sich später nach einigen Tests herausstellte, lag es an dieser Methode, das der fertige Block in EyesWeb während seiner Laufzeit permanent mehr und mehr Arbeitsspeicher anforderte, was EyesWeb nach kurzer Zeit zum Absturz brachte.

EyesWeb-Patch zur manuellen Aufhebung der Kameraobjektivverzerrung
Abhilfe schaffte die Erzeugung eines temporären, als Zielbild der Entzerrung dienenden Bildes nach den Daten des Eingangsbildes. Das Array der ursprünglichen Bilddaten des Eingangsbildes wird schließlich mit Hilfe von cvCopy() durch die Daten des eben erzeugten Bildes, welches nun das entzerrte Eingangsbild darstellt, überschrieben. Gemäß dem EyesWeb-Prinzip, das, wie schon erwähnt auf das Durchschleifen eines Eingangsbildes setzt, wird somit nach dem Freigeben der Ausgabe jenes abgeänderte Eingangsbild wieder ausgegeben. Als zusätzliche Funktionalität wurde die Möglichkeit des Zurücksetzens der Entzerrung beziehungsweise Neustarts der Entzerrungsprozedur über einen Reset-Parameter eingebunden.

Der fertige, Entzerrungsblock, basiert also auf der Erkennung eines Schachbrettmusters durch cvFindChessboardCorners(). Sofern alle Ecken des Musters erkannt wurden, werden die Koordinaten der Punkte in einem Array festgehalten. Mittels dieser Werte, die ja unter Einfluss der Objektivverzerrung stehen und der OpenCV bekannten Geometrie des Schachbrettmusters kann die Funktion cvCalibrateCamera2() die intrinsischen Parameter der Kamera/des Objektivs (das sind unter anderem Brennweite und Versatz des Bildhauptpunktes) und die Koeffizienten der bereits erwähnten Entzerrungsgleichung berechnen. Dies geschieht umso genauer, desto mehr Werte von Bildern, in denen das Schachbrettmuster in jeweils unterschiedlicher Lage zu erkennen ist, zur Verfügung stehen und desto mehr das Muster das aufgenommene Bild ausfüllt. Der Patch besitzt einen eingebauten Countdown, der sich an der eingehenden Bildrate orientiert. Nach Ablauf des Countdowns wird versucht, das zu dem Zeitpunkt verfügbare Bild zu analysieren. Nach der positiven Analyse von drei Bildern wird die Entzerrung durchgeführt und als erstes eben jenes cvCalibrateCamera2() aufgerufen. Anschließend werden die intrinsischen Parameter und die Koeffizienten der Entzerrungsgleichung an die Funktion cvInitUndistortMap(), die die Entzerrungskarte – praktisch ein neues Bildlayout auf Pixelebene – mit x- und y-Koordinaten erstellt, übergeben. Damit wäre die Initialisierung der Entzerrung abgeschlossen und via cvRemap() kann anhand der Entzerrungskarte ab nun jedes in den Block eingehende Bild entzerrt wieder ausgegeben werden.

EyesWeb-Patch zur automatischen Entzerrung
Fortan konnte man sich der Anpassung des EyesWeb-Patches auf unser Projekt widmen. Als sinnvoll wurde hierbei eine automatische Kalibrierungsprozedur angesehen. Der Patch wurde dahingehend erweitert, dass ein Display eine Folge von vier Bildern ausgibt, auf denen ein jeweils leicht versetztes Schachbrettmuster zu sehen ist. Dieses Displayfenster wird vergrößert in der Projektion, die als er weiterter Desktop eingerichtet ist, angebracht. Danach kann über den Button "Start Calibration" die automatische Kalibrierung gestartet werden. Bei eben erwähnten drei erkannten Bildern wird die Kalibrierung beziehungsweise Entzerrung durchgeführt und fortan nunmehr das entzerrte Bild ausgegeben.

Das Display mit den unterschiedlichen Bildern des Schachbrettmusters kann nach abgeschossener Kalibrierung natürlich einfach geschlossen werden. Wie bei der manuellen Entzerrung über den Button "Reset" kann auch natürlich auch hier die Kalibrierung wiederholt werden, indem der Button "Start Calibration" erneut betätigt wird. Der nun vorhandene Patch kann also ohne weiteres an die Spitze des letztendlich gemeinsamen Patches unseres Projektteams gesetzt werden. Einzig an der Zuverlässigkeit hapert es noch, da die Entzerrungsprozedur trotz allem nicht bei jedem Durchlauf ein gewünschtes Ergebnis liefert und somit die Entzerrung mit großer Wahrscheinlichkeit mehrmals durchgeführt werden muss.

Ein detailliertes Tutorial zu diesem EyesWeb-Block findet sich hier: Aufhebung der Objektivverzerrung eines Kamerabildes in EyesWeb mittels OpenCV.

Freistellung der Projektion aus dem Kamerabild

Die Ursache für die Aufgabenstellung, einen Teilbereich aus dem Kamerabild auszuschneiden war dadurch begründet, dass man das Kamerabild nicht auf die genaue Erfassung der Projektion abstimmen kann. In der Folge ergibt sich, dass das Kamerabild immer einen gewissen Überstand enthält; quasi einen Toleranzrahmen, der nicht von der Projektion bedeckt ist. Daraus resultiert dieselbe Grundproblematik wie schon bei der Objektivverzerrung; nämlich dass mehr Aktionsfläche (Ausmaße des Kamerabildes) zur Verfügung steht, als Ausgabefläche (Ausmaße der Projektion). Bei der Extraktion von Koordinaten im Zuge der Mustererkennung würde somit auch hier wieder ein Versatz in der Anwendung hervorgerufen werden.

Das Ziel war also, einen EyesWeb-Patch zu bauen, der die Ausdehnung der Projektion erkennt und diese auf das gesamte Kamerabild auszudehnt. Da nicht davon ausgegangen werden konnte, dass diese sich nach der Installation von Kamera und Projektor immer an der selben Position im Kamerabild befindet, sollte dies durch eine Kalibrierungsfunktion geschehen, die Position und Ausdehnung der Projektion einmalig bestimmt, sodass danach eine beliebige Anwendung projiziert werden kann.

Der erste Blick schweifte über die Palette zur Verfügung stehender EyesWeb-Blöcke. So könnte man die Koordinaten eines umschließenden Rechtecks einfach an einen Extract-Block übergeben, der den umschlossenen Bereich nach den Angaben des Rechtecks extrahieren könnte. Anschließend bestimmt man durch Analyse der Daten des Rechtecks oder des neuen extrahierten Teilbildes dynamisch Breiten- und Höhenmultiplikator, um das extrahierte Teilbild mit Hilfe eines Resize-Blocks auf eine konstante Größe, wie etwa wieder die Größe des ursprünglichen Kamerabildes von 720x576 Pixel, zu vergrößern, damit für die Koordinatenextraktion ein konstanter Werterbereich erreicht wird. Dieser Weg wäre so einfach umzusetzen gewesen, dass er hier fast nicht einmal hätte erwähnt werden müssen. Doch aus unerfindlichen Gründen ist es weder im Extract-Block, noch im Resize-Block möglich, die notwendigen Parameter dynamisch anzupassen, da es keine "Exported Params", keine Variablen, sondern lediglich einmalig vor Start des Patches festlegbare Konstanten sind. Des weiteren war der Quelltext dieses Blocks natürlich nicht unter den Quelltexten, die der EyesWeb-Distribution beilagen, sodass man die Blöcke eventuell einfach hätte abändern können.

EyesWeb-Patch zur Freistellung der Projektion aus dem Kamerabild
Damit stand also fest, dass man sich mit dieser Problematik nochmals genauer auseinandersetzen musste. Eine große Hilfe war allerdings erneut der Quelltext-Pool des Complex Systems & Cognition Laboratory der University of California San Diego, die wiederum bereits eine Freistellungsfunktion umgesetzt haben, die es nun erneut anzupassen galt. In der Revision sieht es relativ einfach aus, aber etwa den Umgang mit der hierbei entscheidenden Funktion cvSetImageROI(), muss man auch erst einmal kennen. Insgesamt ist der Block dennoch bei weitem nicht so komplex wie der Block, der sich mit der Objektiventzerrung beschäftigt; zur Funktionalität des Patches und des Blocks:

Als Voraussetzung wird wie schon bei der Kamerazentrierung abermals schlicht eine weiße Fläche auf der ganzen Projektion ausgegeben; etwa dadurch, dass die Projektion als erweiterter Desktop eingerichtet wird und als Hintergrund eine weiße Fläche verwendet wird. Auf diese Weise soll wiederum ein Hell-Dunkel-Kontrast von Projektion zu Umgebung erzeugt werden. Zu Beginn des Patches findet eine Binarisierung statt, wie sie auch schon bei der Kamerazentrierung erwähnt wurde: Der Binarisierungsschwellwert wird so angepasst, dass zu weißen Bildpunkten lediglich die Pixel, die die Projektionsfläche aufnehmen, binarisiert werden.
Anschließend wird um die weißen Pixel, die also im optimalen Fall möglichst genau die Ausmaße der aufgenommenen Projektion beschreiben, ein umschließendes Rechteck gesetzt. An den Block werden jeweils x- und y-Koordinaten der linken oberen und der rechten unteren Ecke dieses Rechtecks übergeben. Im Block selber werden die eingehenden Koordinaten in den OpenCV-Datentyp CvRect umgesetzt, welcher die Koordinaten der linken oberen Ecke, sowie Breite und Höhe des Rechtecks benötigt. Die Rechteckdaten dienen dazu, die "Region Of Interest" im Bild, das in den Block eingespeist wird, festzusetzen. Diese ROI begrenzt letztendlich den Bereich eines Bildes, auf den fortan alle Funktionen, die auf das Bild angewendet werden, tatsächlich angewendet werden. Im Anschluss wird ein Zwischenbild erzeugt, dass genau die Abmessung des umschließenden Rechtecks besitzt. Hier wird eine ROI gesetzt, die sich über das ganze Bild ausdehnt. Dieses Setzen der ROI ist nötig, da die nachfolgend angewendete Kopierfunktion cvCopy(), die die ROI des Ausgangsbildes in das Zwischenbild kopiert, beim Kopieren von Bildern auf deren ROI zugreift, welche ansonsten beim Zwischenbild ja nicht vorhanden wäre. Nach dem Kopieren wird erneut ein temporäres Bild erzeugt; diesmal mit den Abmessung des Eingangsbildes. Die Funktion cvResize() vergrößert das Bild mit dem freigestellten Bereich auf jenes neue Zwischenbild, sodass man zum einen immer eine konstante Bildgröße und damit einen konstanten Wertebereich für die letztendlich nach dem Patch angesteuerte Mustererkennung parat hat; zum anderen macht EyesWeb es aufgrund des Prinzips der durchgeschleiften Eingangsbilder sowieso erforderlich, dass das auszugebende Bild beziehungsweise das Bild, dessen Bildarraydaten via cvCopy() die Bilddaten des Eingangsbildes überschreibt, exakt dieselbe Größe wie das ursprüngliche Eingangsbild haben muss. Dieses überschriebene Eingangsbild wird letztendlich gemäß EyesWeb-Prinzip nach Freischaltung der Ausgabe ausgegeben. Um also noch einmal kurz und praktisch den Patch zu erläutern: Der Inhalt eines umschließenden Rechtecks wird auf die Größe des Eingangsbildes vergrößert.
Ansonsten besitzt der Block noch einen nicht zu vergessenden Parameter, den man mit einem Button in EyesWeb ansteuern kann. Über diesen Parameter können die Eckdaten des aktuell freigestellten Bildausschnitts festgehalten werden. Dies ist dahingehend wichtig, als dass man natürlich die weiße Fläche, die zur Kalibrierung notwendig ist, gegen die Fläche einer Anwendung austauschen möchte. Nach dem "Schließen" der Koordinaten verbleibt der freizustellende Bereich also an einer festen Position und mit fester Breite und Höhe im Kamerabild. Bei einer Bewegung der Kamera oder des Projektors muss die Kalibrierung neu durchgeführt werden.

Der Patch kann nun einfach hinter den Entzerrungspatch gehängt werden, sodass hiermit eine zweite Stufe der Kalibrierung entsteht. Hinter dieser Kalibrierungsprozedur kann letzten Endes die Bildverarbeitung angefügt werden.

Ein detailliertes Tutorial zu diesem EyesWeb-Block findet sich hier: Freistellung eines Teilbereichs des Kamerabildes in EyesWeb mittels OpenCV.

Bildverarbeitung

Kameratechnik

Um die Projektionsfläche von oben abzufilmen, wurde eine handelsübliche Kamera verwendet, die ein PAL-konformes Signal abgibt. In diesem Zusammenhang sind vor allem die Halbbilder zu nennen, die die typischen Kammeffekte bei bewegten Kanten hervorrufen. Das Videosignal wurde mit Hilfe einer Framegrabber-Karte im Rechner digitalisiert und konnte somit in EyesWeb als Input ausgewählt werden.
Ein Problem stellte die Besonderheit des Aufbaues dar. Die Kamera sollte mit etwas gleichem Abstand die Projektionsfläche abfilmen. Dabei sollte die Kamera in jedem Fall mehr als die Projektionsfläche, aber auch nicht zu viel aufnehmen. Also musste ein entsprechendes Objektiv verwendet werden. Das Kamerabild konnte jedoch erst mit Hilfe eines Distanzring für die Kamera fokussiert werden.

Bei den ersten Tests mit einer Projektion konnte noch mit Hilfe eines schwarzen Molltons auf dem Tisch die Erkennung trotz Projektion möglich gemacht werden, da der Mollton die Projektion kaum reflektierte und sie somit bei der Binarisierung des Videobilds (mit einer geeigneten Schwellwerteinstellung) ausgeblendet wurde. Allerdings schwächte der Mollton die Projektion zu sehr ab, was einen schlechten visuellen Eindruck zur Folge hatte. Deshalb war das Ziel, die Projektion auszublenden, da bei einer Binarisierung des normalen Kamerabildes nicht nur der Hand, sondern auch Bereichen, die zur Projektion gehörten, der Wert "1" zugewiesen wurde. Dies sollen die nebenstehenden Bilder verdeutlichen.

Originalbild
binarisiertes Originalbild

Eine Hintergrundsubtraktion funktionierte gut, solange sich der Inhalt der Projektion nicht bewegt. Bei einer Hintergrundsubtraktion wird ein Referenzbild der Projektion ohne Hand aufgenommen und danach mit dem eigentlichen Kamerabild die absolute Differenz gebildet wird. Aber dass sich der Inhalt der Projektion nicht bewegt, ist natürlich bei einem Billardspiel nicht der Fall.

Eine weitere Möglichkeit war eine Hintergrundsubtraktion, bei der das Referenzbild kumulativ erstellt wird. Diese Methode wird überall dort verwendet, wo sich der Hintergrund langsam, der Gegenstand aber schnell (relativ dazu) ändert. Auch diese Methode konnte nicht angewandt werden, da sich die Kugeln des Billard-Spiels mitunter schneller bewegten als die Hand. Im Zusammenhang mit der Hintergrundsubtraktion sollte erwähnt werden, dass EyesWeb eine erweiterte Version bereitstellt. Der Block dafür heißt "Bgnd-SubMult-Thresh" und ist unter Imaging→Operations zu finden. Dabei wird das Bild gleichzeitig binarisiert, wobei der Schwellwert der Binarisierung abhängig von der Helligkeit des Hintergrundes ist. Dieser Block ist vor allem nützlich, wenn im Binarisierten Gegenstand immer wieder Löcher entstehen, weil Objekt und Hintergrund ähnliche Grauwerte besitzen.

Schließlich machten wir uns die Tatsache zu nutze, dass die Lampen, die in den üblichen Projektoren eingesetzt werden, sogenannte Metalldampflampen sind, in denen Metallatome durch Ionisation in einer elektrischen Entladung zum Leuchten angeregt werden. Diese Funktionsweise bewirkt, dass von der Lampe keine elektromagnetische Strahlung im Infrarot-Bereich abgegeben wird, d.h. die Projektionsfläche im Infrarot-Bereich nicht sichtbar ist. Infrarotlicht liegt im Spektralbereich zwischen sichtbarem Licht und der langwelligeren Mikrowellenstrahlung. Da der Tisch, die Hand und der Queue Infrarotstrahlung, die z.B. von der Sonne oder von einer Infrarotleuchte ausgestrahlt wird, reflektieren, erhält man ein Videobild, das besser für die Binarisierung geeignet ist.
Nun gibt es Kameras, deren CCD-Chip für Infrarot-Licht empfindlich ist und die keinen oder einen beweglichen Filter besitzen, der Infrarot-Licht aussperrt. Bei normalen Kameras, wie z.B. digitalen Fotokameras, verhindert solch ein Filter eine Farbverfälschung. Vor allem bei Überwachungskameras wird dieser Filter nicht oder variabel eingebaut, um auch im Dunkeln möglichst viel Licht einzufangen. Wir benötigten solch eine Kamera, um im Infrarot-Bereich aufnehmen zu können. Zusätzlich wollten wir aber nur das Infrarot-Licht herausfiltern, um die Projektion, die ja nur für den Menschen sichtbares Licht beinhaltet, auszublenden. Dies ist mit einem Infrarot-Filter möglich. So konnte die Projektion ausgeblendet werden.

Binärbild
Originalbild
mit Infrarot-Filter

Als großer Nachteil stellte sich heraus, dass erst relativ spät im Projekt solch ein Infrarot-Filter zur Verfügung stand. Denn schwerwiegende, weitere Probleme konnten erst dadurch erkannt werden.
Eines dieser Probleme war, dass sich die Helligkeitswerte der Tischplatte und des Armes weitaus weniger unterschieden als vorher. Damit war die Vorraussetzung für eine gute Binarisierung nicht mehr gegeben. Der Grund dafür war zum einen, dass der Helligkeitsunterschied im Infrarot-Bereich einfach geringer ist und wir mit unserem bloßen Auge keinen Hinweis darauf haben. Zum anderen war das Bild sehr dunkel und damit bei einer Helligkeitsanpassung und Verstärkung stark verrauscht. Hinzu kam, dass die einzige Infrarot-Lichtquelle die Sonne darstellte, da die im Raum angebrachten Leuchtstoffröhren im Prinzip genauso wie die Lampen im Projektor arbeiten (hier Edelgas statt Metalldampf) und somit auch kein Infrarot-Licht abgeben. Das Licht der Sonne traf durch die Fenster sehr gerichtet von der Seite auf den Tisch bzw. die Hand. Dass die dadurch entstandenen Schatten eine Binarisierung, bei der nur der Arm extrahiert wird, unmöglich machten, leuchtet schnell ein.

Kamera- und Projektoraufhängung: Kamera mit Infrarot-Filter, Infrarotstrahler (schwarzes Rechteck) und Projektor;
man beachte die lila leuchtende IR-Dioden des IR-Strahlers, die mit bloßem Auge nicht zu sehen sind.

Wir benötigten also eine Lichtquelle, die Infrarot-Licht abstrahlt, wie z.B. eine Glühlampe, die aber die Leuchtkraft der Projektion nicht beeinträchtigt. Die Lösung war ein Infrarot-Strahler. Dieser musste direkt neben dem Kamera-Objektiv angebracht werden, da sich sonst wieder für die Kamera sichtbare Schlagschatten ergeben würden. Der Nachteil an diesem Strahler war, dass seine Leuchtkraft relativ schwach und der Abstrahlungswinkel klein war, so dass auf dem Tisch ein "Spot" erschien: Ein radialer Helligkeitsverlauf, der die Ecken des Kamerabildes dunkel ließ. Das Anbringen des Strahlers verbesserte die Lichtverhältnisse dennoch merklich und löste das Problem mit den seitlichen Schlagschatten durch die Sonne hinreichend. Der Helligkeitsunterschied zwischen Arm und Tisch war besonders in den Ecken des Bildes allerdings immer noch relativ gering. Das Ergebnis war immer noch nicht völlig zufrieden stellend. Die ungleichmäßige Ausleuchtung des Tisches durch den Spot des Strahlers machte die bereits vorher eingesetzte Hintergrundsubtraktion obligatorisch.
Nach Abschluss des Projektes standen jedoch zwei weitere Strahler zur Verfügung die die das Binarisierungsergebnis deutlich verbesserten.

Bildvorverarbeitung

Vor der Pixeluntersuchung musste zuerst eine geeignete Vorverarbeitung in EyesWeb stattfinden.
Zuerst wurde das Videobild in ein Grauwertbild umgewandelt und dann binarisiert, um den Benutzer (bzw. dessen Finger-/Queuespitze) eindeutig vom Untergrund unterscheiden und danach die Pixeluntersuchung anwenden zu können. Um unterschiedliche Lichtverhältnisse bei der Aufnahme auszugleichen, wurde der Schwellwert der Binarisierung an einen Schieberegler gekoppelt, so dass man diesen manuell nachregeln konnte.
Nach der Binarisierung wurde noch die morphologische Operation "Opening" angewendet, um Störungen im Bild zu entfernen und den Umriss der Spitze genauer darzustellen.
Da der selber geschriebene Block (genauer gesagt eine darin enthaltene Funktion) beider Lösungen ein Grauwertbild und kein Binärbild als Input erwartete, musste das Bild anschließend wieder in ein Grauwertbild umgewandelt werden. Bei der "Abstand-Idee" wurden außerdem noch nach der Umwandlung des Videobildes in ein Grauwertbild eine Hintergrundsubtraktion und eine Medianfilterung durchgeführt, was einige Störungen im Bild entfernte und zu etwas besseren Ergebnissen führte.

Bildverarbeitung

Für die Erkennung der Fingerspitze bzw. der Queuespitze in einem Videobild gab es mehrere Ideen. Die Herangehensweise war dabei immer die gleiche. Zunächst wurden markante Merkmale des zu erkennenden Objekts gesucht, die dann zur Erkennung herangezogen werden können. Danach wurde die technische Realisierbarkeit untersucht, d.h. wie kann man dieses markante Merkmal erkennen und wie kann man das Gesuchte extrahieren. Weiter wurde nach der Robustheit gegenüber Störungen gefragt.
Schließlich wurden Ausnahmefälle gesucht, bei denen die Erkennung nicht funktionieren wird und es wurden Lösungen eruiert, diese Ausnahmefälle abzudecken. Trotz der vielen Möglichkeiten, die EyesWeb bietet, kann es passieren, dass man eine bestimmte Funktion bzw. einen Block vergeblich sucht. Hier bietet sich für technisch versierte Anwender die Möglichkeit, einen Block selbst zu programmieren.

EyesWeb stellt für die Version 3.3 einen Wizard und ein Add-In für Microsoft Visual C++ 6.0 zur Erstellung eigener EyesWeb-Blöcke zur Verfügung, was zusätzlich gut dokumentiert ist. In der Version 4.0 gibt es diesen Wizard nicht mehr. Stattdessen wird in einem langen Dokument ein unverhältnismäßig umständlicheres Verfahren beschrieben, wie das Grundgerüst für einen EyesWeb-Block in einer neueren Entwicklungsumgebung, wie beispielsweise Visual Studio .NET 2003, zu erstellen ist. Neben Berichten von geringerer Stabilität war dies der Hauptgrund für uns, die ältere Version (EyesWeb 3.3) zu verwenden. Der dabei mitgelieferte Wizard führt bei der Anlegung eines neuen Blockes in Visual C++ 6.0 mehrere Dialoge durch, in denen Eigenschaften des Blockes wie Anzahl und Art der Inputs, Parameter usw. angegeben werden können. Am Ende dieser Prozedur steht ein Grundgerüst, das man nur noch mit dem auszuführenden Code füllen muss. Der Wizard stellt auch automatisch ein, dass das Ergebnis des Builders eine DLL ist, die dann noch als Steuerelement in der Registry registriert werden muss. Falls dies erfolgreich geschehen ist, taucht der neue Block in EyesWeb auf und kann verwendet werden.
Um uns in die Programmierung eines eigenen EyesWeb-Blockes zu erleichtern, folgten wir den Rat höherer Semester, das von EyesWeb bereitgestellte Tutorial für Programmierer durchzuführen. Bei den Tutorials traten viele Probleme mit dem Angeben und der Verlinkung der einzubindenden Bibliotheken auf, die nur schwer zu lösen waren. Aber auch wenn die Erstellung der DLL in Visual C++ reibungslos geklappt hatte, konnte es zu Problemen kommen. So kam es mehrmals vor, dass nach der erfolgreichen Erstellung einer DLL EyesWeb ohne eine Fehlermeldung abstürzte. Schon relativ schnell kam der Gedanke auf, ob es nicht besser wäre statt des Blockes gleich eine kleinere, auf OpenCV basierende Anwendung zu schreiben, die die gleiche Funktionalität wie der spätere EyesWeb-Patch besitzt und somit EyesWeb ersetzt. So könnte man auch vermeiden, dass an sich programmiertechnisch einfache Zusammenhänge in EyesWeb kompliziert ausgedrückt werden müssen.

Wie bereits erwähnt, musste für die Umsetzung der ersten Idee ein eigener Block in Visual Studio 6 geschrieben werden. Dazu wurden im Wizard ein Input (das Videobild), zwei Outputs (X- und Y-Koordinaten des ersten weißen Pixels) inklusive deren Datentypen (IDTSkalar und IDTImage) und der Name ("Erstes Weisses Pixel") des Blocks definiert. Die Definition von externen Parametern war nicht erforderlich. Der Block ist passiv, d.h. er wird nur aktiv, wenn er ankommende Daten an seinen Eingängen registriert. In Visual Studio wurden dann folgende Schritte im Execute-Teil programmiert:
Zuerst musste das Videobild, welches von EyesWeb als "IDTImage" eingelesen wurde, mit "pIn->GetIplImage" in ein "IplImage" umgewandelt werden. Dies war nötig, denn die benutzte OpenCV-Funktion benötigt ein "IplImage". Danach wurde der Ursprung des Bildes mit "in->origin" verschoben, da dieser nicht wie üblich links oben sondern links unten lag:

IplImage* in = NULL;
pIn->GetIplImage((void**) &in);
in->origin = 0;

Danach folgte die Funktion zur pixelweisen Untersuchung des Bildes:

for (y=0;y<in->height;y++)
{
  for (x=0;x<in->width;x++)
  {
    if((unsigned char) *(in->imageData + in->widthStep * y + x))
    {
      outX->SetIntValue(x);
      outY->SetIntValue(y);
      found = TRUE;
      Message("Gefunden!\n");
      if (found) break;
    }
    if (found) break;
  }
  if (found) break;
}

Diese Funktion durchläuft das Videobild von links nach rechts, wobei sie nach dem Zeilenende in die nächste Zeile wechselt. Sobald die Funktion ein weißes Pixel findet, übergibt sie die X- und Y-Koordinaten dieses Pixels an die Variablen "outX" und "outY" und verlässt die for-Schleife. Die X- und Y-Koordinate des Pixels wird den IDTScalar-Werten der Outputs zugewiesen, wobei zu bemerken ist, dass eine IDTScalar-Klassenmethode zu verwenden ist, um die Integerwerte umzuwandeln. Durch das Entsperren der Outputs werden, wie bei jedem anderen Block auch, die Ausgabewerte auch wirklich ausgegeben.
Wichtig zu wissen ist, dass "widthStep" die Bildweite in Byte und nicht in Pixel ausgibt. Beachtet man das nicht, hat das zur Folge, dass bei einem Binärbild als Input, bei dem ein Pixel nur ein Bit in Anspruch nimmt, ein falscher Wert für die Y-Koordinate ausgegeben wird. Der Block kann also nur richtig arbeiten, wenn er als Input ein Grauwert-Bild bekommt, das bekanntermaßen ein Byte für ein Pixel benötigt. Um anderen Benutzern das Arbeiten mit dem "Erstes Weißes Pixel"-Block zu erleichtern, sollte in der Init-Funktion des Blocks eine Abfrage der Pixeltiefe stehen. Wenn diese nicht der eines Grauwertes, nämlich einem Byte entspricht, sollte der EyesWeb-Patch nicht gestartet werden können und eine entsprechende Fehlermeldung mit dem Hinweis auf die Notwendigkeit eines Grauwert-Bildes als Input ausgegeben werden. Dies wurde bisher noch nicht implementiert.

Der selbst programmierte Block wurde dann so in EyesWeb eingesetzt, dass er nach der Vorverarbeitung (siehe vorhergehender Abschnitt) das erhaltene Grauwertbild untersucht und die Ergebniskoordinaten per OSC an FLOSC übergibt. Außerdem wurden zur besseren Visualisierung und zur Überprüfung, ob die Spitze richtig erkannt wurde, auf dem Ursprungsbild die erkannte Spitze mit einem kleinen Kreuz markiert und die Koordinaten als Text ausgegeben.

EyesWeb-Block "Abstand"
Der zweite Lösungsansatz war wesentlich robuster als die Idee mit dem ersten weißen Pixel und hatte den Vorteil, dass man nun von allen Seiten her ins Bild fassen konnte und die Finger-/Queuespitze trotzdem sehr gut erkannt wurde.
Für diesen Lösungsansatz gingen wir davon aus, dass die Finger-/Queuespitze denjenigen Pixel enthält, der am weitesten vom Schwerpunkt des ins Bild ragenden Armes entfernt ist (Bild: Rote Linie). Unsere Annahme war, dass sich der Schwerpunkt des Armes die meiste Zeit relativ nahe am Bildrand befindet, da die Finger-/Queuespitze wenig Masse liefert und somit nicht relevant für die Berechnung des Schwerpunktes sein müsste. Nach der Programmierung und nach einigen Tests stellte sich aber heraus, dass der Schwerpunkt doch weiter zur Finger-/Queuespitze tendierte als angenommen. Deshalb wurden oft Pixel am Bildrand als Finger-/Queuespitze detektiert, da diese weiter vom Schwerpunkt entfernt waren als die tatsächlichen Finger-/Queuespitzenpixel.

Die Lösung dieses Problems war die Implementierung einer zweiten Bedingung: Das Pixel sollte nicht nur am weitesten vom Schwerpunkt entfernt, sondern auch am nächsten zum Mittelpunkt des Bildes sein (Bild: Gelbe Linie). Diese Lösung schien zwar auch noch nicht ausgereift zu sein, bei Tests stellte sie sich aber als sehr robust raus. Selbst wenn Teile des Benutzerkopfes mit ins Bild ragten (was beim Ausprobieren des Malprogramms bzw. des Billards durchaus oft passierte), wurde die Spitze weiterhin sehr gut erkannt.

Bildmittelpunkt (gelb), Schwerpunk (blau) und erkannte Spitze (rot)

Diese Lösung wurde schließlich für das Projekt verwendet, da das Billardspiel es erfordert, von allen Seiten her mit der Projektion zu interagieren. Jedoch hatte dieser Algorithmus noch einen Haken. Bei dem bestehenden Algorithmus war die erste Bedingung für die Erkennung der Fingerspitze der maximale Abstand zum Schwerpunkt. Danach wurde abgefragt, ob der untersuchte Punkt auch einen kleineren Abstand zum Mittelpunkt des Bildes hat. Wenn diese beiden Bedingungen erfüllt wurden, wurde der aktuelle Pixel so lange als Fingerspitze gespeichert bis ein anderer Punkt die beiden Kriterien erfüllte. Dies stellte kein Problem dar, solange der Arm von unten ins Bild eingeführt wurde. Dabei wurden die Pixel an der Fingerspitze vor denen am Rand des Bildes, also am Arm des Benutzers untersucht. Letztere erfüllten vielleicht die erste Bedingung, die des größeren Abstandes zum Schwerpunkt, jedoch nicht die zweite, die des kleineren Abstandes zum Mittelpunkt. Problematisch wurde es jedoch, wenn der Arm von oben ins Bild eingeführt wurde de. Hier wurden zuerst die Pixel am Rand abgefragt und einer davon als Ergebnis gespeichert. Die Punkte an der Fingerspitze, die "Richtigen" also, konnten, wie weiter oben bereits erwähnt, des Öfteren weiter vom Schwerpunkt entfernt sein, die erste Bedingung nicht erfüllen und wurden somit nicht mehr als Fingerspitze in Betracht gezogen.
Um diesen Mangel zu beseitigen, wurde nun die Lage des Schwerpunktes, dessen Koordinaten bereits zur Verfügung stehen, mit einbezogen. Und zwar wird das Bild zeilenweise von links oben beginnend durchlaufen, wenn der Schwerpunkt in der unteren Hälfte des Bildes liegt. Ist dem nicht so, wird das Bild zeilenweise von links unten beginnend durchlaufen. So wird annähernd sichergestellt, dass die Punkte am Rand nach den Punkten an der Fingerspitze untersucht werden. Der Vollständigkeit halber müsste man das Ganze auch auf die linke und rechte Seite des Kamerabildes ausweiten und das Bild dann spaltenweise durchsuchen. Die Praxis zeigte jedoch, dass die vorgenommene Änderung ausreichend war.
Der nun vorhandene Algorithmus hat immer noch einige theoretische Ausnahmen, die aber in der Praxis selten zu tragen kommen. So kann es beispielsweise sein, dass Punkte am Arm weiter vom Schwerpunkt entfernt sind, wenn der Arm in das Videobild reicht. Dabei könnten Punkte am Bildrand früher als solche an der Fingerspitze untersucht werden. Eine weitere Möglichkeit, diese Ausnahmen auszumerzen, wäre, die Bedingung der kleineren Entfernung zum Mittelpunkt vor die der größeren Entfernung zum Schwerpunkt zu setzen. Dies hätte jedoch den Nachteil, dass bei sehr viel mehr Punkten beide Abstände, den zum Schwerpunkt und zum Mittelpunkt, berechnet werden müssten. Da die dafür benötigte Wurzelberechnung relativ rechenintensiv ist, haben wir uns für die Abfrage der Lage bzw. des Y-Wertes des Schwerpunktes und das davon abhängige Durchlaufen des Bildes entschieden.

Ein detailliertes Tutorial zu diesem EyesWeb-Block findet sich hier: Fingerspitzenerkennung in EyesWeb mittels OpenCV.

Programmierung

Programmierung kann augenscheinlich nicht komfortabler sein als mit Flashs integrierter Skriptsprache ActionScript: Keine Variablen müssen initialisiert werden, Datentypen werden von selbst konvertiert und der Compiler bügelt jede Schlampigkeit im Code für den User völlig transparent von selbst aus und meckert (fast) nie. Dieser Schlaraffenlandstatus hat allerdings nur bis zu einem bestimmten Projektumfang Bestand: Wird die magische Schwelle überschritten, treten so viele unangenehme Seiteneffekte der bequemen Formfreiheit zu Tage, dass die bisherige Arbeit fast nicht mehr weiterverwendbar ist. Hier soll nun der dabei entstandene Quellcode dokumentiert werden.

FLOSC und XML

Die Fingerposition, die über diverse Bildverarbeitungsalgorithmen ermittelt wird, wird in EyesWeb bereits auf die Projektionsfläche normiert. Das Spiel wird immer im Vollbildmodus ausgeführt und der Referenzpunkt sowie die Achsenorientierung bei EyesWeb und Flash sind gleich (Referenz links oben, positives x: Bewegung nach rechts, positives y: Bewegung nach unten). Daher entspricht die Eingangsposition nach Normierung von Eingangsauflösung (PAL-DV 720x576) auf Ausgangsauflösung (1024x768) dem selben Punkt in der Flashanimation. Die Finger-Positions-Koordinaten verlassen EyesWeb über das ScalarToOSC-Socket als OSC-Datenstrom via UDP. Jedes OSC-Paket enthält immer zuerst die x-, dann die y-Koordinate – entsprechend der Reihenfolge der exportierten Parameter in EyesWeb. Der Parametername der OSC-Pakete lautet "mouse-coord".
Die sinnvollste und einfachste Möglichkeit, diesen Datenstrom in Flash zu empfangen, ist das Flash-eigene XML-Socket. Flash empfängt hierbei einen XML-Datenstrom und stellt die Pakete samt vieler nützlicher Event-Handler zur Verfügung. Um den binären OSC-Datenstrom also in einen XML-Datenstrom zu konvertieren, verwendet man am besten den kostenlosen Javabasierten Protokoll-Umsetzer-Server FLOSC. Dieser tut nichts anderes, als auf der einen Seite UDP-verpackten OSC-Binärpaketen zu empfangen, diese nach Informationen auszuwerten und in entsprechende XMLPakete zu konvertieren und auf der anderen Seite als XML-TCP-Datenstrom an das Flash-XML-Server-Socket zu schicken. FLOSC läuft als Serverprozess und benötigt nur 2 konstante Parameter, die über die Kommandozeile übergeben werden: Den UDP-Port, auf dem er einkommende OSC-Pakete empfagen soll, sowie den TCP-Port, an den er die konvertierten Pakete weiterschicken soll. Ein Serverstart eines Servers, der auf UDP-Port 7000 und TCP-Port 3000 lauscht, sieht also folgendermaßen aus:

java -classpath . Gateway 7000 3000
Starten des FLOSC-Servers
die im FLOSC-Paket enthaltene Beispiel-Anwendung
Der Server liegt nicht als vorkompiliertes .exe-Binary vor, sondern in Form mehrerer bytecodezwischenkompilierter Javaklassen ("Gateway.class" etc.). Daher muss der Aufruf über das Java-Runtime-Environment direkt in dem Verzeichnis, in dem alle Klassen liegen, erfolgen. Viele Standard-Java-Runtime-Environment-Installationen nehmen jedoch nicht von sich aus das momentane Arbeitsverzeichnis (".") in die Liste der Standardsuchverzeichnisse für weitere Klassen auf, so dass dies wie in diesem Fall über den zusätzlichen Parameter " –classpath ." manuell geschehen musste. Die FLOSC-Website bot keinerlei

Informationen darüber.

In unserem konkreten Fall laufen alle Teil-Prozesse des Systems auf dem selben Computer: Sowohl die Bilderkennung als auch das Spiel selbst. Dank der Netzwerkarchitektur des Kommunikationsweges ist es aber auch kein Problem, die beiden Elemente auf 2 Rechner aufzuteilen. In diesem Fall kommt vermutlich eine durch die Netzwerkkarte verursachte zusätzliche Latenz hinzu, da die Pakete nicht wie beim Einzelplatzsystem über das kernelinterne Routing direkt ohne Umweg über die Hardware ans gewünschte lokale Socket, sondern übers Netzwerkkabel an ein externes Socket geschickt werden.

Das EyesWeb-ScalarToOSC-Socket trägt also im Feld IPAdresse den Wert "localhost" und im Feld Port den Wert 7000, da der FLOSC-Server wie beschrieben auf der selben Maschine an Port 1250 lauscht. Die Flash-Applikation instantiiert beim Start ein Objekt der Klasse "XMLSocket", dass ebenfalls die IP-Adresse "localhost"/127.0.0.1 sowie Port 7000 als Parameter bekommt. Damit ist die Verbindung erfolgreich zustandegekommen.

der ScalarToOSC-Block in EyesWeb

In dem FLOSC-Paket ist dankenswerterweise bereits ein Beispiel-Flash-Dokument enthalten, das die Verarbeitung des XML-Datenstroms samt Verbindungsauf- und Abbau und Fehlerbehandlungsroutinen bereits umfasst. Um das Rad nicht neu zu erfinden, habe wurde diese Funktionen größtenteils übernommen und leicht angepasst. Sie liegen alle in der Hauptzeitleiste. Die folgende Funktion connect() bringt den Stein ins Rollen:

107 function connect() {
108   mySocket = new XMLSocket();
109   mySocket.onConnect = handleConnect;
110   mySocket.onClose = handleClose;
111   mySocket.onXML = handleIncoming;
112   if (!mySocket.connect(IPaddress, port)) {
113     statustext.text = "Verbindungsfehler";
114   }
115 }
116
117 function disconnect() {
118   mySocket.close();
119   mySocket.connected = false;
120   statustext.text = "getrennt";
121 }

In connect wird also das benötigte XMLSocket-Objekt instantiiert, das sich um den XML-Datenstrom kümmern wird. Den verschiedenen Event-Handlern onConnect (reagiert, sobald die Funktion XMLSocket.connect aufgerufen wird, also ein Verbindungsaufbau stattfindet), onClose (reagiert, sobald mittels XMLSocket.close die Verbindung wieder geschlossen wird) und onXML (reagiert, sobald ein XML-Paket angekommen ist) werden die Funktionen zugewiesen, die sich um die jeweiligen Fälle kümmern sollen. Meist war nur der Fehlerausgabentext zu übersetzen ("Verbindungsfehler" statt "connection error") sowie das Anzeigefenster zu modifizieren: Das Textfenster für die Debugausgaben heißt "statustext" und liegt auf der Hauptzeitleiste. Es wird an anderen Stellen immer wieder referenziert. Die oben bereits angekündigte Flash-typische Stilschlampigkeit zeigt sich hier an der globalen Ansprache der globalen Variablen IPadress und port. Die von den Eventhandlern angesprochenen Funktionen sind meist zur Fehlermeldungsausgabe da:

128 function handleConnect(succeeded) {
129   if (succeeded) {
130     statustext.text = "Connected to "+IPaddress+" on port "+port+"\n";
131     mySocket.connected = true;
132   } else {
133     mySocket.connected = false;
134     statustext.text = "Connect fehlgeschlagen";
135   }
136 }
137
138 function handleClose() {
139   statustext.text = ("The server at "+IPaddress+" has terminated the connection.\n");
140   mySocket.connected = false;
141   numClients = 0;
142 }

Die Funktion handleIncoming() wird immer "onXML" ausgeführt, also immer, wenn ein neues XML-Datenpaket ankommt. Für die spätere Bearbeitung wird noch von Bedeutung sein, wie oft dies geschieht, also wie hoch die XML-Datenrate ist. Der durchschnittliche Wert liegt bei ca. 10 Paketen pro Sekunde. Um sich nicht auf diese grobe Schätzung verlassen zu müssen, wird die momentane Datenrate einfach einmal pro Sekunde durch Zählung aller bis dahin eingegangenen Pakete ermittelt. Dies beugt eventuellen Einbrüchen in der Performance vor und sorgt immer für einen korrekten Wert.

123 function handleIncoming(xmlIn) {
124   xmlcount++;
125   parseMessages(xmlIn);
126 }

Die globale (Number-)Variable xmlcount wird also bei jedem einkommenden Paket um 1 erhöht. Eine weitere Funktion mit dem Namen "resetxmlcount" liest die Anzahl der eingegangenen Pakete aus, sichert den Wert zur weiteren (unsynchronisierten) Weiterverarbeitung (lastmeasuredxmlps) und setzt die Laufvariable xmlcount wieder auf 0. "resetxmlcount" wird über eine Intervallschaltung jede Sekunde aufgerufen.

219 function resetxmlcount() {
220   if(mySocket.connected){
221     statustext.text = this.xmlcount+" messages pro Sekunde";
222     this.lastmeasuredxmlps = this.xmlcount;
223     this.xmlcount = 0;
224   }
225 }
 
260 XMLintervalId = setInterval(this, "resetxmlcount", 1000);


Nachdem nun also innerhalb von handleIncoming der Paketzähler erhöht wurde, wird die Funktion "parseMessages" aufgerufen, die als Übergabeparameter das gerade angekommene XML-Paket erhält und für das Auspacken und Auswerten ("Parsing") der Nutzlast des Pakets zuständig ist. Zum besseren Verständnis hier zunächst die Struktur eines von FLOSC ankommenden XML-Pakets:

<OSCPACKET ADDRESS="127.0.0.1" PORT="1068" TIME="0">
  <MESSAGE NAME="mouse-coord">
    <ARGUMENT TYPE="i" VALUE="1155" />
    <ARGUMENT TYPE="i" VALUE="121" />
  </MESSAGE>
</OSCPACKET>

Das XML-Paket ist also aufgebaut wie eine Zwiebel, deren Schalen nacheinander entfernt werden müssen. Um auf den "Nutzlast"-Knoten ARGUMENT abzusteigen, geht die Funktion rekursiv vor: Lautet der Name des momentanen Knotens MESSAGE, sind nach erneutem Abstieg die beiden ARGUMENT-Knoten erreicht. Lautet er nicht MESSAGE (wie z.B. im allerersten Fall – OSCPACKET), ruft sich die Funktion selbst auf und gibt sich das um eine Schale entpackte Paket mit. Da jedes XML-Paket aus EyesWeb in jedem Fall zuerst den x- und dann den y-Wert enthält, selbst wenn sich einer der beiden Werte nicht verändert hat und eigentlich nicht neu übertragen werden müsste, wurde die Auswertung der ARGUMENT-Knotenhart codiert, was zur Lesbarkeit beiträgt. Hier der momentan relevante Teil der Funktion:

144 function parseMessages(node) {
145   if (node.nodeName == "MESSAGE" && node.attributes.NAME == "mouse-coord") {
152     child = node.firstChild;
153     if (child.nodeName == "ARGUMENT") {
154       this.myxmlmouse.x = child.attributes.VALUE;
155     }
156     child=child.nextSibling;
157     this.myxmlmouse.y = child.attributes.VALUE;
178   } else {
179    // look recursively for a message node
180     for (var child = node.firstChild; child != null; child=child.nextSibling) {
181       parseMessages(child);
182     }
183   }
184 }

Die Funktion wertet also das XML-Paket aus, springt zum x-Wert, speichert diesen in einem Point-Objekt mit dem Namen myxmlmouse, springt zum y-Wert und speichert diesen im selben Point-Objekt. Hierbei werden die Kamerabildkoordinaten direkt auf die Bildschirmpixel gemapped. Da jedoch die Kamera ein Bild der Größe 720x576 (PAL-DV) liefert, müssen die Koordinatenwerte auf die neuen Dimensionen (1024x768) gestreckt werden:

154 this.myxmlmouse.x = (child.attributes.VALUE/720)*1024;
156 child=child.nextSibling;
157 this.myxmlmouse.y = (child.attributes.VALUE/576)*768;

Die Klasse Point spielt im Folgenden eine große Rolle:

001 import flash.geom.Point;
 
051 var myxmlmouse:Point = new Point(0, 0);

Die Point-Klasse ist eine mit Flash 8 eingeführte sehr simple und gleichzeitig extrem mächtige Klasse, die eigentlich nur einen geometrischen Punkt abspeichern kann (siehe Eigenschaften Point.x und Point.y) und die grundlegenden geometrische Punktoperationen beherrscht: Point.distance() (der Abstand zwischen zwei Punktobjekten), Point.add() und Point.subtract() (Addition und Subtraktion von 2 Point-Objekten) sowie Point.length (Länge der Strecke von (0;0) bis zu diesem Punkt) und Point.normalize() (Streckung der "length" des Punktes, also Vektor-Skalierung). All diese Eigenschaften der Klasse werden zu einem späteren Zeitpunkt noch sehr wichtig, momentan zählt aber nur, dass die Koordinaten sinnvoll in einem zusammenhängenden Objekt anstatt in 2 getrennten Number-Variablen gespeichert werden.

Cursor-Positions-Interpolation

Es ist nun also der Zustand erreicht, dass sich im Arbeitsspeicher ein Objekt befindet, das zu jedem Zeitpunkt die jeweils aktuelle Fingerposition enthält. Alle nachfolgenden Prozesse greifen immer auf dieses besagte Point-Objekt "myxmlmouse" zu. Dieser Wert ändert sich mit der XML-Datenrate, die wie erwähnt bei ca. 10 Hz liegt. Im Thema, dem fingergesteuerten Zeichenprogramm, reichte dies nicht aus, da schnelle Positionsänderungen nur unzureichend abgetastet wurden, was sehr eckigen Malstrichen zur Folge hatte. In dem Zeichenprogramm wurde diesem Sachverhalt begegnet, indem ab einem bestimmten Abstand zwischen den einzelnen Fingerpositionen – also ab einer bestimmten Bewegungsgeschwindigkeit – eine Bezierkurve gezeichnet wurde, was die Kurve zwar nicht physikalisch exakt, aber ästhetisch befriedigend, glättete.

Im Billardspiel soll der Finger dazu benutzt werden können, die weiße Kugel anzustoßen. Diese hat aber nur eine begrenzte Größe. Es besteht bei einer derart geringen Abtastfrequenz die Möglichkeit, dass der Finger die Kugel durchstößt und keiner der Samplepunkte innerhalb der Kugel liegt. Die Kugel bleibt also völlig unbeeindruckt liegen, weil die Kollisionsdetektion, auf die später noch genauer eingegangen wird, nicht auslöst. Wie kann diesem Problem nun beigekommen werden? Es wurde keine Möglichkeit gefunden, die XML-Datenrate bzw. die Datenrate der OSC-Pakete aus Eyesweb zu erhöhen, weshalb nur eine Variante übrig blieb: Interpolation der Positionswerte.
Was muss die Interpolation leisten? Richtung und Geschwindigkeit des Stoßes werden weiterhin aus den "echten" Cursorpositionen aus EyesWeb errechnet. Die Interpolation soll also nur dazu dienen, das Raster zu verengen, was die Wahrscheinlichkeit einer Kollisionsdetektion beim Kugelabstoß erhöht. Richtungs- und Geschwindigkeitsinformationen der dazwischenliegenden Punkte bleiben zwischen 2 exakten Werten immer gleich, also genügt hier eine simple lineare Interpolation. Der Effekt der "Ausmittelung" der Richtungsinformationen ist hier sogar erwünscht: Es ist nicht wichtig, dass immer der genaue lokale Bewegungswinkel verfügbar ist, sondern, dass das "globale" Mittel der Fingerbewegung bekannt ist, was z.B. kleinere Zitterer verzeiht.

Problematik der zu geringen Cursor-Datenrate - Abtasttheorem
Das Prinzip ist einfach:

Um die Lücke zwischen abgetasteter Position A und der folgenden abgetasteten Position B zu füllen, werden einfach die momentan in A bestehende Geschwindigkeit und die Bewegungsrichtung hergenommen und daraus die geschätzte nächste Cursorposition errechnet ("Prädiktion"). Die Strecke zwischen A und geschätztem B wird durch einen konstanten Faktor (5 hat sich als ideal erwiesen) geteilt und gemäß der aktuell gemessenen XML-Datenrate wird jede 1/5tel Periodendauer ein Fünftel-Strecken-Punkt hinzuaddiert.
Bei schnellen Richtungsänderungen ist die Interpolation äußerst fehlerbehaftet: Es entsteht ein sägeblattähnlicher Positionsverlauf, weil der prädizierte Punkt und die errechnete Strecke dorthin nicht mit dem tatsächlichen "echten" Punkt übereinstimmen. Da im Billard aber nur gerade Bewegungen ausgeführt werden, ist dies zu vernachlässigen.
Alle Kollisionsabfragen greifen wie erwähnt auf das Point-Objekt "myxmlmouse" zurück, also muss nur dieses Objekt mit den interpolierten Zwischenwerten aktualisiert werden. Dies geschieht direkt an der Quelle, also in der bereits zitierten Funktion parseMessages().

Der beschriebene Interpolationsalgorithmus benötgt also den momentanen Bewegungsvektor des Cursors (Richtung und Geschwindigkeit), der sich aus Differenz zwischen dem letzten und dem vorletzten gemessenen Punkt ergibt. Für eine eventuelle spätere Mittelung über mehrere Punkte habe wurde dafür einen Array aus Punktobjekten angelegt: lastxmlmouse. Dieses Array wird bei jedem XML-Paketeingang am Ende mit dem aktuellen Punkt gefüllt, worauf der jeweils erste Eintrag des Arrays "herausfällt", also gelöscht wird (Stack-Prinzip):

173 if(this.lastxmlmouse.push(this.myxmlmouse.clone()) > 3){
174   this.lastxmlmouse.splice(0, 1);
175 }

Jetzt sind alle Parameter vorhanden und es kann munter interpoliert werden. Aus 1/5tel des momentan gemessenen Eintreffintervalls ergibt sich das Intervall, in dem die Punkte dazuerfunden und in myxmlmouse geschrieben werden sollen:

146 // Interpolationsintervall löschen, interpolationsfunktion freigeben
147 if(this.interpolationsintervallid != null) {
148   clearInterval(this.interpolationsintervallid);
149 }
150 this.interpolationszaehler = 0;
 
177 this.interpolationsintervallid = setInterval(this, "interpolateXMLPoints", 1000/((this.interpolationsmenge+1)*this.lastmeasuredxmlps));

Durch diese Intervallschaltung, die bei jedem einkommenden Paket aktualisiert wird, wird also das vielfache ausführen der Funktion interpolateXMLPoints veranlasst, so dass die zeitliche Lücke zwischen den exakten Werten gerade passend überbrückt wird. Ein Zähler(interpolationszaehler) teilt der Interpolationsfunktion mit, der wievielte Punkt des zu überbrückenden Abstands nun berechnet werden soll. Hier nun die Interpolationsfunktion interpolateXMLPoints():

186 function interpolateXMLPoints(){
187   if((lastxmlmouse.length > 1) && (interpolationszaehler<interpolationsmenge)) {
188     var tempvektor:Point = lastxmlmouse[lastxmlmouse.length -1].subtract(this.lastxmlmouse[lastxmlmouse.length - 2]);
189     tempvektor.normalize(tempvektor.length/interpolationsmenge*(interpolationszaehler+1));
 
192     var newposition:Point = new Point(0 , 0);
193     var altesx:Number = Number(lastxmlmouse[lastxmlmouse.length-1].x);
194     var altesy:Number = Number(lastxmlmouse[lastxmlmouse.length-1].y);
195     newposition.x = altesx + tempvektor.x;
196     newposition.y = altesy + tempvektor.y;
197
198     mauskreuzgruen._x = newposition.x;
199     mauskreuzgruen._y = newposition.y;
200
201     //ab hier wird scharf geschossen !
202     myxmlmouse = newposition.clone();
203     checkxmlrolls();
204
205     interpolationszaehler++;
206   }
207 }
Vorgehensweise der Cursor-Positions-Interpolation
Der Code in dieser Funktion ist auffällig umständlich. Das Ganze hätte mit deutlich weniger Zeilen ausgedrückt werden können. Flash wehrte sich hier aber beständig dagegen und führte fehlerhafte Typecastings durch. So wurden in dieser Funktion die Koordinatenwerte (Number!) hartnäckig in Strings konvertiert, was den +-Operator zum Verknüpfungsoperator machte, obwohl ja eine simple Addition gewünscht war. Deshalb lieferte z.B. 17 + 53 als Ergebnis 1753 anstatt 70. Mit der abgedruckten Variante hat es funktioniert. Vermutlich ein Flash-internen Compilerbug, der erst ab einer bestimmten Projekt-Komplexität zu Tage kommt. Es liegt nun also ein geeignet interpolierter Cursor mit – dank Interpolation – versechsfachter Abtastfrequenz vor. Nun muss also nur noch die Kollisionsabfrage aufgerufen werden, die bestimmt, ob der Cursor soeben die weiße Kugel berührt hat. Eine Berührung anderer Spielkugeln mit dem Queue würde laut Billardregeln ein Foul verursachen, da nur die weiße Kugeln berührt werden darf. Aus Praktikabilitätsgründen wurde somit die Möglichkeit weggelassen, andere Kugeln außer der weißen zu berühren. Die Kollisionsabfrage in Form der Funktion checkxmlrolls wird bei jedem exakten Cursorwert-Update (innerhalb von parseMessages) und bei jedem interpolierten Cursorwert (innerhalb von interpolateXMLPoints) ausgeführt. Über die genaue Funktionsweise wird der nächste Abschnitt Informationen liefern.
209 function checkxmlrolls(){
210   statustext.text += myxmlmouse.toString() + " ";
211   if((Point.distance(myxmlmouse, weissekugel.position) < (KUGELGROESSE/2)) && !(xmlovervorher)){
212     myXMLMouseDispatcher.sendOnXMLOver("weissekugel");
213     xmlovervorher = true;
214   } else if(Point.distance(myxmlmouse, weissekugel.position) > (KUGELGROESSE/2)){
215     xmlovervorher = false;
216   }
217 }

Der Transfer und die Aufbereitung der Koordinaten ist nun also geschafft und die Flashapplikation kann nun bereits mit den erkannten Fingerkoordinaten bedient werden.

Objektorientierte Modellierung

Die physikalischen Vorgänge beim Billardspiel entsprechen im wesentlichen dem elastischen Stoß. Dieser besagt, dass 2 kollidierende Körper die Impulsbeträge tauschen, die auf den jeweils anderen wirken. Dieser Betrag entspricht nur im Spezialfall, dass die Richtung des Bewegungsvektors von Kugel A genau dem Kollisionswinkel mit Kugel B entspricht, dem gesamten Impuls von Kugel A: Trifft also Kugel A völlig gerade und mittig auf die stillstehende Kugel B, bleibt Kugel A stehen und Kugel B bewegt sich mit Impuls und Richtung von Kugel A weiter – vorausgesetzt Kugel A und Kugel B haben die selbe Masse, was in der Realität meist der Fall ist. Streifen sich die beiden Kugeln nicht-mittig, werden mittels des Kräfteparallelogrammes die Kraftkomponenten errechnet, die auf die jeweils andere Kugel wirken und im nächsten Schritt ausgetauscht.
Innerhalb eines Billardspiels wird diese Kernfunktion ständig benötigt: Jede Kollision der Kugeln untereinander läuft nach der selben Gesetzmäßigkeit ab. Außerdem hat jede Kugel einen eindeutig festgelegten Wert und einen individuellen Bewegungsvektor. Es bietet sich also an, die Spielelemente objektorientiert zu modellieren.

Die Klasse "Kugel"

Der Grundgedanke war, dass jede Kugel selbst wissen sollte, wie schnell sie sich wohin bewegt und sich um die Positionierung ihrer grafischen Repräsentation auf dem Bildschirm selbst kümmern sollte. Für grafische Spielelemente auf dem Bildschirm kommt in Flash ohnehin nur eine Grundelementklasse in Frage: MovieClip. Ein MovieClip kann von sich aus bereits repositioniert, skaliert sowie ein- und ausgeblendet werden, erfüllt also alle Grundeigenschaften der grafischen Repräsentation der Kugel. Um diese Eigenschaften zu nutzen, vererbt man sie am besten an seine eigene Klasse bzw. erbt sie in der Klasse. Die 16 MovieClips der einzelnen Kugeln werden in Flash dann einfach mit der Erbenklasse verknüpft und sind fortan mit den zusätzlichen Eigenschaften der eigenen Klasse ausgestattet:

17 class Kugel extends MovieClip {
 
19 private var __kugelwert:Number;
 
21 private var __position:Point;
 
25 private var __geschwindigkeit:Point;
26 private var __mass:Number = 1;
 
28 private var __eingelocht:Boolean;
 
55 public function onEnterFrame() {
56   this.move();
 
60 }
 
73 public function move(){
 
91   this._x = this.position.x;
92   this._y = this.position.y;
93
94   updateAfterEvent();
95 }
96
97 public function bande(kolflaeche:Rectangle){
98 // Bandenchecks
 
119 }
 
121 public function einlochen(lmp:Point){
 
125 }
 
216 }

Wir sehen also, dass jeder Kugel-MovieClip seine eigene Position (in Form eines Point-Objektes), seinen Geschwindigkeitsvektor (dessen Koordinaten ebenfalls in einem Point-Objekt gespeichert sind), seinen Kugelwert (Aufschrift), sowie weitere Informationen über die Masse, die aber als für alle Kugeln konstant anzusehen ist, und ein Booleanflag, ob die Kugel eingelocht wurde.

Kollision der Kugeln A und B mit ihren Bewegwungsvektoren a und b
Berechnung der Teilbeträge der Bewegungsvektoren in Richtung des Kollisionspartners
Ermittlung der Betragsdifferenz
Neuberechnung der Vektoren

Weiterhin kann sich die Kugel selbst bewegen (inkl. Bremsbeschleunigung bzw. Reibungsfaktor), von einer Bande abprallen und eingelocht werden (kurze Animation, in der die Kugel zum jeweiligen Lochmittelpunkt wandert und langsam transparent wird). Die für die Spielauswertung wichtige Information, ob die Kugel "voll" der "halb" ist, ergibt sich aus dem Kugelwert: Ein Kugelwert von 0 steht für die weiße Kugel, Kugel 1-7 ist "voll", Kugel 8 hat ohnehin einen Sonderstatus und Kugel 9-15 sind "halb". Die Spielauswertung selbst wird weiter unten beim Interface angesprochen.
Nachdem die Kugeln fertig in 3D modelliert waren, mussten lediglich die Grafiken in die entsprechenden Kugel-MovieClips zentriert importiert werden.

Die Funktion onXMLOver() implementiert einen selbst definierten Event-Handler, der unter Verwendung der später dokumentierten Klasse "XMLMouseDispatcher" realisiert wurde. Erhält ein Kugelobjekt ein XMLOver-Event, nämlich dann, wenn der XML-Cursor über der entsprechenden Kugel liegt, wird ein Anstoß getriggert. Dies passiert aber nur, falls es sich um die weiße Kugel handelt, während alle anderen Kugeln davon unbehelligt bleiben. Viele Variablennamen tragen einen Namen, der mit "__" beginnt. Dies ist allgemeine Konvention für "private"-Eigenschaften. Fast alle Eigenschaften werden in den Klassen als "private" deklariert, während der Zugriff darauf über ein Paar aus get- und set-Funktion abläuft. Wird also auf die Eigenschaft this.geschwindigkeit zugegriffen, so wird in Wirklichkeit die (meist am Ende der Klassendefinition) definierte Funktion "public function get geschwindigkeit()" (Lesezugriff) bzw. "public function set geschwindigkeit()" (Schreibzugriff) ausgeführt, die beide intern auf __geschwindigkeit zugreifen. Dies hält die Möglichkeit offen, den Variablenzugriff direkt zu kontrollieren und z.B. transparente Modifikationen (Grenzwertbehandlungen u.Ä.) zu integrieren.

vereinfachtes Klassendiagramm des Billardspiels

Noch können sich die Kugeln nur unabhängig voneinander in eine Richtung bewegen. Ihre Richtungen und Geschwindigkeiten werden ihnen zugewiesen, woraufhin sie sich von anderen Kugeln oder Banden völlig unbeeindruckt in diese Richtung bewegen bis sie aufgrund des Reibungsfaktors stehen bleiben. Es fehlt also noch die Information, ob und wann eine Kugel mit einer anderen Kugel bzw. einer Bande kollidiert und was daraufhin mit den beiden Beteiligten geschieht. Dies ist aber keine direkte Eigenschaft der Kugel, weshalb die fehlende Instanz, die die Überwachung aller Kollisionen übernehmen sollte, als separate Klasse mit dem Namen "Kollisionschecker" modelliert wurde.

Grundlagen der Kollisionsdetektion

Flash bietet innerhalb der MovieClip-Klasse eine simple Kollisionsabfrage (hitTest). Diese zieht aber nur das umschreibende Rechteck des MovieClips zu Rate und ist für die kreisförmige Natur der Kugel natürlich gänzlich ungeeignet, weil damit der Fall möglich wäre, dass 2 Kugeln sich mit ihren "unsichtbaren Ecken" berühren und voneinander abprallen. Eine Kollision zweier Kugeln ist dann gegeben, wenn der Abstand zwischen den Mittelpunkten beider Kugeln kleiner ist als die Summe der Radien beider Kugeln. Dieser Vergleich funktioniert in allen Winkeln korrekt und ist obendrein sehr einfach implementierbar. Flash bietet die Möglichkeit, den Referenzpunkt eines MovieClips entweder in die Ecken, die Seitenmitten oder den Mittelpunkt des umschreibenden Rechtecks zu legen. Bei den Kugeln ist also der Mittelpunkt des quadratischen MovieClips deckungsgleich mit dem Mittelpunkt des Kreises bzw. der "Kugel".

Das Event "Kollision zwischen 2 Kugeln" wurde somit erfolgreich definiert. Die Detektion der Bandenkollision erscheint auf den ersten Blick noch einfacher: Da die Billardspielfläche rechteckig ist, kann einfach ermittelt werden, ob sich die jeweilige Kugel innerhalb dieses Rechtecks befindet. Liefert diese Überprüfung false, wird die kugelinterne Bandenreflexionsroutine getriggert. Die Koordinaten des Bandenrechtecks wurden durch simples Abmessen ermittelt, nachdem das Billardtisch-Hintergrundbild auf der Bühne korrekt ausgerichtet worden war. Als Referenzpunkt der Kugel dient auch hierbei wieder der Mittelpunkt. Es ist zu beachten, dass die Überprüfung auf Beinhaltung des Mittelpunktes erst dann false liefert, wenn bereits die Hälfte der Kugel über das Rechteck hinausragt. Die einfachste Lösung hierfür ist, das Kollisionsrechteck auf jeder Seite um 1x Radius der Kugeln zu verkleinern.

An dieser Stelle tritt eine weitere, äußerst simple und mächtige Basisklasse auf den Plan, die mit Flash 8 eingeführt wurde: Die Klasse "Rectangle" stellt eine Repräsentation eines Rechtecks dar und verfügt über viele nützliche Bordmittel: Rectangle.inflate() vergrößert oder verkleinert ein Rechteckobjekt um einen angegebenen x- und y-Wert und Rectangle.containsPoint() überprüft, ob ein mitgegebenes Point-Objekt in diesem Rechteck enthalten ist. Es existiert also in der Hauptzeitleiste ein konstantes globales Rectangle-Objekt SPIELFLAECHE, das die gemessenen tatsächlichen Abmessungen des Billard-Pools beinhaltet. Aus diesem Rechteck wird dann durch Subtraktion des Kugelradius von allen Seiten die tatsächliche KOLLISIONSSPIELFLAECHE ermittelt. Dies geschieht in Abhängigkeit der Konstante KUGELGROESSE, die den Durchmesser der Kugeln definiert. Somit kann durch Verändern von nur einer Konstante die Größe aller Kugeln verändert werden, während die Kollisionsdetektion weiterhin korrekt arbeitet werden.

2 import flash.geom.Rectangle;
 
19 var KUGELGROESSE:Number = 34;
 
23 var SPIELFLAECHE:Rectangle = new Rectangle(62, 160, 900, 448);
24 var KOLLISIONSSPIELFLAECHE:Rectangle = SPIELFLAECHE.clone();
25 KOLLISIONSSPIELFLAECHE.inflate(-(KUGELGROESSE/2), -(KUGELGROESSE/2));

Der Billardpool besteht aber natürlich nicht nur aus einem Rechteck mit durchgehender Bande, sondern besitzt 6 Löcher, von denen die Kugeln natürlich nicht abprallen sollten. Die Detektion gestaltet sich hier wesentlich schwieriger, vor allem da die runde Form der Löcher nur schwierig durch Rechtecke approximierbar ist. Die daraus resultierende Ungenauigkeit der Einlochdetektion geht aber immer zu Gunsten des Spielers. Eine Kugel trifft also in mehreren Fällen ins Loch als in der Realität. Hier die Abfolge der Abfragen:

Liegt die Kugel außerhalb der Kollisionsspielfläche ? (Grundbedingung für Bandenreflexion)

  • Ja:
    Liegt sie innerhalb der Kollisionsfläche, die senkrecht zwischen den mittleren beiden Löchern aufgespannt ist?
    • Ja:
      Liegt die Kugel sogar noch außerhalb des "wirklichen" Spielfeldes, das den tatsächlichen Bildkoordinaten entspricht?
      • Ja:
        Ein Einlochen in ein Mittelloch wird registriert. Die Information, welches Mittelloch bespielt wurde, wird anhand dessen gewonnen, ob der y-Wert der Kugel oberhalb oder unterhalb des Mittelpunktes der Spielfläche liegt.
    • Kollision zweier Kugeln an ihren "unsichtbaren Ecken"
      Nein:
      Liegt die Kugel innerhalb einer Eckenlochzone (lochzonelo, lochzonero, lochzonelu, lochzoneru)?
      • Ja:
        Liegt die Kugel sogar noch außerhalb des "wirklichen" Spielfeldes?
        • Ja:
          Ein Einlochen in ein Eckenloch wird registriert. Die Information, welches Loch, ist bereits über den vorherigen Abfrage-Ast bekannt.
      • Nein:
        Eine Bandenreflexion der Kugel wird getriggert. Der Bandentyp (obere, untere, linke oder rechte Grenze) wird ermittelt, indem festgestellt wird, ob x- oder y-Koordinate der Kugel die Grenzen der Kollisionsspielfläche über- oder unterschreiten.

Die Klasse "Kollisionschecker"

Somit wäre der Aufgabenbereich der Kollisions-Check-Instanz umrissen. Die Klasse "Kollisionschecker" kennt also alle Kugeln in Form eines Referenzen-Arrays, sämtliche Spielflächen und Kollisionszonen, die Kugelgröße und schließlich den Ausgabe-Socket ihrer Ergebnisse: Die im nächsten Abschnitt beschriebene Klasse "GameManager", die die Kommunikation mit dem Spielinterface übernimmt.

Kollisionszonen auf dem Billardtisch, relativ den "echten" Kugelabmessungen bzw. dem Mittelpunkt
05 import flash.geom.Point;
06 import flash.geom.Rectangle;
07
08
09 class Kollisionschecker {
10 private var __kugelobjekte:Array = new Array();
11 private var __spielflaeche:Rectangle;
12 private var __kollisionsspielflaeche:Rectangle;
13 private var __mittelloecherkollisionsspielflaeche:Rectangle;
14 private var __lochzonelo:Rectangle;
15 private var __lochzonero:Rectangle;
16 private var __lochzoneru:Rectangle;
17 private var __lochzonelu:Rectangle;
18 private var __kugelgroesse:Number;
19 private var __bounce:Number;
20 private var __gamemanager:GameManager;
21
22 public function Kollisionschecker(spielflaeche:Rectangle, kolflaeche:Rectangle, mittelkolflaeche:Rectangle, lzlo:Rectangle, lzro:Rectangle, lzru:Rectangle, lzlu:Rectangle, kugelgroesse:Number, kugelchen:Array, intervall:Number, bounce:Number, gm:GameManager) {...}

Die zentrale Methode der Klasse Kollisionschecker, check(), überprüft bei jedem Aufruf jede Kugel zunächst daraufhin, ob sie gerade mit einer anderen Kugel kollidiert ist. Ist dies der Fall, wird der elastische Stoß dieser beiden Kugeln berechnet und jedem Kollisionspartner der neue Bewegungsvektor zugewiesen.

Die Implementation dieser Berechnung kostete sehr viel Zeit, da sich ein hartnäckiger Bug nicht entfernen ließ: Bei jeder Kollision war die Summe der Beträge der resultierenden Bewegungsvektoren minimal größer als die Summe der Beträge der ursprünglichen Vektoren. Dazu kam ein zweiter, nicht entfernbarer Bug: Die Kollisionsdetektion versagte zu völlig rätselhaften Zeitpunkten, so daß sich teilweise 2 Kugeln überlappen konnten. Die Kombination dieser beiden Bugs führte zu einem sehr amüsanten Effekt: Überlappten sich 2 Kugeln, wurden mehrfach – statt nur einmal – Kollisionen berechnet, die ja eine größere Gesamtenergie mit sich brachten. Dies beschleunigte die Kugeln sehr stark, woraufhin diese wieder mit anderen Kugeln überlappten und eine Kettenreaktion hervorriefen. Dieser Effekt erhielt von uns den Namen "Billard-Kraftwerk"...

Es war bis zum Schluss nicht möglich, diesen Bug zu fixen. Glücklicherweise war mehr oder weniger zufällig im Internet unter eine fertige Billard-Kugel-Actionscript-Klasse mit bereits eingebauter Kollisionsdetektion zu finden – wohlgemerkt erst als die Version bereits kurz vor der Vollendung stand. Die Klasse gefiel zwar vom Design nicht, da dort die Kollisions-Check-Instanz als ein implizit erzeugtes globales Objekt implementiert war, auf das alle anderen Kugeln zugriffen, aber wenigstens schien die Kollisionsberechnung zu funktionieren und außerdem waren einige CPU-schonende Optimierungen bereits integriert. Alle nützlichen und besonders elegant gelösten Details wurden aus der gefundenen Klasse extrahiert und auf das Klassendesign angepasst.

Hier zunächst der Teil der Methode check(), der die Kollision von Kugeln untereinander detektiert und den elastischen Stoß berechnet:

36 public function check(){
37   for(var i=0; i < this.kugelobjekte.length-1; i++){
38     for(var j=i+1; j < this.kugelobjekte.length; j++){
39       if(Point.distance(this.kugelobjekte[i].position, this.kugelobjekte[j]. position) <  this.kugelgroesse){
40         // Houston, we have a collision
41         var ball1:Kugel = this.kugelobjekte[i];
42         var ball2:Kugel = this.kugelobjekte[j];
43         var winkelberechnungspunkt:Point = ball1.position.subtract(ball2.position);
44         var winkel:Number = Math.atan2(winkelberechnungspunkt.y, winkelberechnungspunkt.x);
45         var cosa:Number = Math.cos(winkel);
46         var sina:Number = Math.sin(winkel);
47         var vx1:Number = cosa * ball1.geschwindigkeit.x + sina * ball1.geschwindigkeit.y;
48         var vy1:Number = cosa * ball1.geschwindigkeit.y - sina * ball1.geschwindigkeit.x;
49         var vx2:Number = cosa * ball2.geschwindigkeit.x + sina * ball2.geschwindigkeit.y;
50         var vy2:Number = cosa * ball2.geschwindigkeit.y - sina * ball2.geschwindigkeit.x;
51         var p:Number = ball1.mass * vx1 + ball2.mass * vx2;
52         var v:Number = vx1 - vx2;
53         var vx1f:Number = (p-ball2.mass*v)/(ball1.mass+ball2.mass);
54         var vx2f:Number = v+vx1f;
55         ball1.geschwindigkeit.x = cosa * vx1f * -this.bounce - sina * vy1;
56         ball1.geschwindigkeit.y = cosa * vy1 + sina * vx1f * -this.bounce;
57         ball2.geschwindigkeit.x = cosa * vx2f * -this.bounce - sina * vy2;
58         ball2.geschwindigkeit.y = cosa * vy2 + sina * vx2f * -this.bounce;
63         var midx:Number = (ball1.position.x + ball2.position.x)/2;
64         var midy:Number = (ball1.position.y + ball2.position.y)/2;
65         var offset = this.kugelgroesse * .5;
66         ball1.position.x = midx + cosa * offset;
67         ball1.position.y = midy + sina * offset;
68         ball1._x = ball1.position.x;
69         ball1._y = ball1.position.y;
70
71         ball2.position.x = midx - cosa * offset;
72         ball2.position.y = midy - sina * offset;
73         ball2._x = ball2.position.x;
74         ball2._y = ball2.position.y;
75       }
76     }
77   }

Das einzig Unschöne daran: Die Anzahl der Variablen könnte bei Verwendung der Point-Klasse mitsamt ihrer integrierten Methoden auf einen Bruchteil reduziert werden. Für diesen Optimierungsschritt blieb allerdings keine Zeit. Ist die Kollisionsberechnung abgeschlossen, wird auf Bandenreflexion bzw. "Lochkollision", also Einlochen, überprüft. Außerdem werden die Geschwindigkeiten aller Kugeln aufsummiert und in eine Variable gespeichert, um die Information zu erhalten, wann alle Kugeln stillstehen, also wann der momentane Zug des Spielers abgeschlossen ist:

079 var geschwindigkeitssumme:Number = 0;
080
081 for(var i=0; i < this.kugelobjekte.length; i++){
082   // Aufsummieren, danach checken ob alle Kugeln stehen
083   geschwindigkeitssumme+=this.kugelobjekte[i].geschwindigkeit.length;
084
085   if(!(this.kollisionsspielflaeche.containsPoint(this.kugelobjekte[i].0position))){
086     // entweder weiterrollen bis zum mittelloch, eckloch oder bandenkollision
087     // mittelloch:
088     if(this.mittelloecherkollisionsspielflaeche.containsPoint(this.kugelobjekte[i].position)){
089       // im schmalen Korridor zu nem Mittelloch
090         if(!(this.spielflaeche.containsPoint(this.kugelobjekte[i].position))){
091           // mit dem Schwerpunkt über dem lochrand - drin !
093           if(this.kugelobjekte[i].position.y < kollisionsspielflaeche.top){
094             //oberes mittelloch
095             this.kugelobjekte[i].einlochen(new Point(512, 133));
096             this.gamemanager.eingelocht(this.kugelobjekte[i].kugelwert, 2);
097           }else{
098             //unteres mittelloch
099             this.kugelobjekte[i].einlochen(new Point(512, 633));
100             this.gamemanager.eingelocht(this.kugelobjekte[i].kugelwert, 5);
101           }
102           if(this.kugelobjekte[i].kugelwert != 0){
103             this.kugelobjekte.splice(i, 1);
104           }
105         }
106       } else if((this.lochzonelo.containsPoint(this.kugelobjekte[i].position)) || (this.lochzonero.containsPoint(this.kugelobjekte[i].position)) || (this.lochzoneru.containsPoint(this.kugelobjekte[i].position)) || (this.lochzonelu.containsPoint(this.kugelobjekte[i].position))) {
107         // eckloch:
108         if(!(this.spielflaeche.containsPoint(this.kugelobjekte[i].position))){
109           // drin !
111           if(this.lochzonelo.containsPoint(this.kugelobjekte[i].position)){
112             //links oben
113             this.kugelobjekte[i].einlochen(new Point(40, 140));
114             this.gamemanager.eingelocht(this.kugelobjekte[i].kugelwert, 1);
115           }else if(this.lochzonero.containsPoint(this.kugelobjekte[i].position)){
116             //rechts oben
117             this.kugelobjekte[i].einlochen(new Point(984, 140));
118             this.gamemanager.eingelocht(this.kugelobjekte[i].kugelwert, 3);
119           }else if(this.lochzoneru.containsPoint(this.kugelobjekte[i].position)){
120             //rechts unten
121             this.kugelobjekte[i].einlochen(new Point(984, 628));
122             this.gamemanager.eingelocht(this.kugelobjekte[i].kugelwert, 6);
123           }else if(this.lochzonelu.containsPoint(this.kugelobjekte[i].position)){
124             //links unten
125             this.kugelobjekte[i].einlochen(new Point(40, 628));
126             this.gamemanager.eingelocht(this.kugelobjekte[i].kugelwert, 4);
127           }
128           if(this.kugelobjekte[i].kugelwert != 0){
129             this.kugelobjekte.splice(i, 1);
130           }
131         }
132       } else {
133         //trace("Bande " + this.kugelobjekte[i]._name);
134         this.kugelobjekte[i].bande(this.kollisionsspielflaeche);
135       }
136     }
137   }
138   if(geschwindigkeitssumme == 0){
139     this.gamemanager.zugimgange = false;
140     if(this.gamemanager.zugimgangevorher == true){
141       // wenn die kugeln grad stehen bleiben
142       this.gamemanager.sendeKugeln();
143     }
144   } else {
145     this.gamemanager.zugimgange = true;
146   }
147 }

Wurde eine Kugel eingelocht, wird zunächst die kugelinterne Einlochanimation getriggert. Dies geschieht durch einen Aufruf von Kugel.einlochen(mittelpunkt:Point), die die Übergabe des Lochmittelpunktes erfordert. Die Kugel bewegt sich daraufhin auf diesen Lochmittelpunkt zu, wird dabei transparent und entfernt sich dann selbst von der Zeitleiste.

113 this.kugelobjekte[i].einlochen(new Point(40, 140));

Daraufhin wird der Wert der eingelochten Kugel sowie der Index des Loches, in das sie versenkt wurde, an den GameManager gemeldet.

114 this.gamemanager.eingelocht(this.kugelobjekte[i].kugelwert, 1);

Zum Abschluss muss aber noch die Referenz auf das Kugelobjekt aus dem Array des Kollisionscheckers entfernt werden, damit nicht bei jedem folgenden Schritt Karteileichen, also bereits versenkte und unsichtbare Kugeln, auf Kollisionen überprüft werden. Die weiße Kugel wird hierbei ausgenommen, da sie sich beim Einlochen nicht entfernt sondern sofort wieder an den Startpunkt positioniert.

128 if(this.kugelobjekte[i].kugelwert != 0){
129 this.kugelobjekte.splice(i, 1);
130 }

Die Klasse "GameManager"

Der GameManager ist also das Ziel der Ergebnisse, die der Kollisionschecker liefert. Eigentlich sollte GameManager auch bereits die Auswertungsarbeiten (Spiel- und Zugmanagement, Gewinnerfeststellung etc.) übernehmen, diese Aufgabe wurde aber bereits ins Spielinterface integriert, so dass GameManager im Prinzip nur die eingelochten Kugeln cached und nach Spielzugende an das Interface zum Auswerten übergibt. Der GameManager kennt also den Spielinterface-MovieClip, speichert die innerhalb des Zuges eingelochten Kugeln mittels der Methode eingelocht(kugelwert:Number, loch:Number) und überträgt bei Zugende mittels sendeKugeln() die innerhalb dieses Zuges eingelochten Kugeln ans Interface in Form der Methode ZugAuswertung. Ab hier übernimmt das Interface die weitere Verarbeitung.

06 class GameManager {
07   private var __eingelochtekugeln:Array;
08   private var __zugimgange:Boolean;
09   private var __zugimgangevorher:Boolean;
10   private var __interfacemovieclip:MovieClip;
 
20   public function eingelocht(kugelwert:Number, loch:Number){
21   if(kugelwert != 0){
22     this.eingelochtekugeln.push(kugelwert);
23   } else {
24     this.eingelochtekugeln.push("weiss");
25   }
26   this.ausgabe = kugelwert + " eingelocht, insgesamt " + this.eingelochtekugeln.length;
27 }
28
29 public function sendeKugeln(){
30   this.__interfacemovieclip.ZugAuswertung(this.eingelochtekugeln);
31   this.eingelochtekugeln.splice(0);
32   this.__zugimgangevorher = false;
33 }
 
39 public function set zugimgange(zig:Boolean):Void{
40   this.__zugimgange = zig;
41   if(zig == true){
42     // damit zugimgangevorher auch wieder auf true springt
43     this.__zugimgangevorher = true;
44   }
45 }

Die Methode sendeKugeln() wird vom Kollisionschecker aus immer dann getriggert, wenn ein Stillstand aller Kugeln festgestellt wurde. Dies passiert aber mit der in Flash festgelegten Framerate der Hauptzeitleiste – also 60 mal pro Sekunde. Ein Auswerten des Zuges soll bei jedem Stillstand aber nur einmal ausgeführt werden, was ein Konstrukt aus Booleanflags (__zugimgangevorher und __zugimgange) notwendig macht.

Die Klasse "XMLMouseDispatcher"

Diese Klasse befasst sich mit der grundsätzlichen Problematik, dass die Standard-Mouse-Event-Handler "onRollOver", "onPress", "onRollOut" etc. bei der Verwendung eines via XML-Socket hereinkommenden Koordinatenpaares als "Mauscursor" nicht zur Verfügung stehen. Der geneigte Flash-Programmierer ist aber an dieses Denkmuster so gewöhnt, dass es erstrebenswert erschien, diese Event-Handler in vergleichbarer Form nachzubauen: "onXMLOver", "onXMLOut", "onXMLPress" und "onXMLRelease". Die Klasse entstand bereits während der Erstellung des Malprogrammes fürs Thema: Dort fanden die neu definierten Event-Handler bei den Buttons im Steuerinterface für Farbwahl, Strichdicke etc. Anwendung, da die Buttons über einen onRollOver-Effekt verfügten, der auch maustypisch getriggert werden wollte.
Flash bietet hierfür eine spezielle – interessanterweise größtenteils undokumentierte – Klasse namens EventDispatcher, die den Event-Kommunikations-Mechanismus nebst Erstellung eigener Event-Handler zur Verfügung stellt.

01 import mx.events.EventDispatcher;
02
03
04 class XMLMouseDispatcher
05 {
06 private var dispatchEvent:Function;
07 public var addEventListener:Function;
08 public var removeEventListener:Function;
09
10 public function XMLMouseDispatcher()
11 {
12   EventDispatcher.initialize(this);
13 }
14
15 public function sendOnXMLOver(thename):Void
16 {
17   this.dispatchEvent({type:"onXMLOver", target:this, name:thename});
18 }
19 public function sendOnXMLOut(thename):Void
20 {
21   this.dispatchEvent({type:"onXMLOut", target:this, name:thename});
22 }
23 public function sendOnXMLPress(thename):Void
24 {
25   this.dispatchEvent({type:"onXMLPress", target:this, name:thename});
26 }
27 public function sendOnXMLRelease(thename):Void
28 {
29   this.dispatchEvent({type:"onXMLRelease", target:this, name:thename});
30 }
31 }

Die EventDispatcher-Klasse wird durch eine Mischform von Vererbung und Interface-Implementation benutzt. Wie genau dies im Detail funktioniert, war nicht herausfinden. Diese funktionierende Variante entstand nach ausgiebiger Internetrecherche in zahllosen Flash-Foren. In der Hauptzeitleiste des Flash-Films wird also eine Instanz dieses XMLMouseDispatchers erstellt. Daraufhin werden diesem Objekt über die Methode addEventListener() sämtliche Objekte übergeben, die auf entsprechende Events reagieren können sollen. Im Falle des Billardspiels ist die weiße Kugel die einzige Kugel, die mit dem Finger oder dem Queue beeinflusst werden soll. Alle anderen werden nur mittels Kollisionen beeinflusst, so dass das "Aus-Versehen-Anstoßen" einer Spielkugel, was zum Foul führen würde, von vorne herein unmöglich ist.

274 var myXMLMouseDispatcher:XMLMouseDispatcher = new XMLMouseDispatcher();
275
276 myXMLMouseDispatcher.addEventListener("onXMLOver", weissekugel);

Ab diesem Zeitpunkt kann also die Klasse des Objekts weissekugel (also Kugel) eine Funktion onXMLOver() definieren, die getriggert wird, sobald ein entsprechendes Event geworfen wird. Das Event-Werfen muss natürlich ebenfalls getriggert werden: Die bei den XML-Funktionen bereits besprochene Funktion checkxmlrolls() verursacht in folgender Zeile einen Eventwurf an weissekugel, die daraufhin ihren Bewegungsvektor aus der Cursorposition berechnet und sich in Bewegung setzt:

212 myXMLMouseDispatcher.sendOnXMLOver("weissekugel");

Da checkxmlrolls() bei jedem ankommenden XML-Paket ausgeführt wird, kann es sein, dass mehrere male hintereinander das Event geworfen wird. Die Kugel kann also während des Rollens nachgeschoben werden. Dies ist aber unerwünscht und wird mit einem weiteren Boolean-Flag-Konstrukt abgefangen (xmlovervorher). Dies beseitigt gleichzeitig einen weiteren unerwünschten Nebeneffekt: Der neue Vektor der weißen Kugel berechnete sich in der ersten Variante aus der Geschwindigkeit des XML-Cursors, also die Positionsänderung betrachtet über die momentan gemessene XML-Datenrate, sowie dem Winkel zwischen Tangentenpunkt, an dem der Cursor die Kugel berührt, und dem Mittelpunkt der Kugel. Die Kugel genau geradeaus zu stoßen war also nur möglich, wenn man sie genau mittig erwischte. Dies war mühsam genug, wurde aber häufig dadurch erschwert, dass beim "Durchdringen" der Kugel auf der gegenüberliegenden Seite ein zweiter Anstoß detektiert wurde und die Kugel letztendlich auf einen zurollte. Durch das Booleanflag-Konstrukt wird nun die weiße Kugel für Berührungen gesperrt, bis sie erneut stillsteht. Außerdem wird die Richtung, die der Kugel beim Anstoßen zugewiesen wird, nicht mehr aus besagtem Winkel zwischen Berührungspunkt und Kugelmittelpunkt berechnet, sondern aus dem Winkel der vorherigen Cursorpunkte. Die Kugel rollt also in der eigentlich "gemeinten" Richtung los.

Das Programm

Nachdem viele Bestandteile des Spiels bereits dargestellt wurden, soll nun abschließend darauf eingegangen werden, wie die einzelnen Elemente in der Hauptzeitleiste von Flash zusammengesetzt werden und ineinander greifen.

Das Billardspiel benötigt eine Bildschirmauflösung von 1024x768 was der nativen Auflösung des im Projekt verwendeten Beamers entspricht. Es ist natürlich erstrebenswert, die komplette Projektionsfläche auszunutzen. Dafür muss der Flashplayer zunächst auf Vollbild-Darstellung gestellt werden. Außerdem muss im Hinblick auf die Deckungsgleichheit zwischen kalibriertem Kamerabild und projiziertem Spiel sichergestellt sein, dass die Position des Spiels innerhalb des maximierten Flashplayerfensters fixiert bleibt und nicht durch Scrolling verändert werden kann. Der Vollständigkeit halber wird auch die Maus ausgeblendet, damit sie nicht "aus Versehen" innerhalb der Projektion sichtbar ist.

1 import flash.geom.Point;
2 import flash.geom.Rectangle;
3
4 fscommand("fullscreen", true);
5 fscommand("allowscale", false);
6 fscommand("showmenu", false);
7
8 Mouse.hide();
9
10 Stage.scaleMode = "noScale";
11
12 var resizeListener:Object = new Object();
13 resizeListener.onResize = function() {
14 Stage.align = 'TL’;
15 };
16
17 Stage.addListener(resizeListener);

Zu Beginn werden Konstanten festgelegt, die sich größtenteils selbst erklären. Die const-Anweisung wie in C/C++ ist in ActionScript nicht enthalten. Die wichtigsten Konstanten wurden dadurch Großbuchstaben gekennzeichnet. Im Prinzip sind sämtliche Deklarationen von Variablen, also jegliches Vorkommen des Schlüsselwortes var, in Flash unnötig. Die folgenden Deklarationen geschehen also hauptsächlich aus Übersichts- und Dokumentationsgründen.

19 var KUGELGROESSE:Number = 34;
20 var DAEMPFUNGSFAKTOR:Number = 0.996; // kleiner 1 , 0.997 bei 60 fps
21 var BOUNCE:Number = -1;
22
23 var SPIELFLAECHE:Rectangle = new Rectangle(62, 160, 900, 448);
24 var KOLLISIONSSPIELFLAECHE:Rectangle = SPIELFLAECHE.clone();
25 KOLLISIONSSPIELFLAECHE.inflate(-(KUGELGROESSE/2), -(KUGELGROESSE/2));
28 var MITTELLOECHERSPIELFLAECHE:Rectangle = new Rectangle(490, 120, 44, 528);
29 var MITTELLOECHERKOLLISIONSSPIELFLAECHE:Rectangle = MITTELLOECHERSPIELFLAECHE.clone();
31 MITTELLOECHERKOLLISIONSSPIELFLAECHE.inflate(-(KUGELGROESSE/8), -(KUGELGROESSE/2));
32 var LOCHZONELO:Rectangle = new Rectangle(60, 158, 37, 37);
33 var LOCHZONELU:Rectangle = new Rectangle(60, 573, 37, 37);
34 var LOCHZONERO:Rectangle = new Rectangle(927, 158, 37, 37);
35 var LOCHZONERU:Rectangle = new Rectangle(927, 573, 37, 37);

Besonders hervorzuheben ist die Konstante BOUNCE, die ihren Ursprung in der integrierten BillardBall-Klasse von bit-101.com hat. Sie hat Einfluss auf das Verhältnis zwischen Einfalls- und Ausfallswinkel bei der Kollisionsberechnung und steht per Default auf -1.

37 var kugelobjekte:Array = new Array();
38
39 // Um die Geschwindigkeitsinformationen korrekt berechnen zu können, müssen die FPS  sowie die XML-Datenrate bekannt sein
40 var fpszaehler:Number = 0;
41 var lastmeasuredfps:Number = 0;
42
43 var xmlcount:Number = 0;
44 var lastmeasuredxmlps:Number = 0;
Entstehung unerwünschter Doppelstöße
Beseitigung des Effekts durch Sperren der Kugel

Mit Hilfe dieser Variablen werden also die XML-Datenrate sowie die momentane tatsächliche Framerate ermittelt. Der Grund dafür ist, dass die Geschwindigkeit des XML-Cursors die XML-Datenrate als Zeitbasis hat, während die Kugelgeschwindigkeiten sich auf die Framerate von Flash beziehen. Am Berührungspunkt zwischen beiden Geschwindigkeiten, also beim Anstoß der weißen Kugel, müssen die Geschwindigkeiten auf die gleiche Zeitbasis gebracht werden, wofür XML-Datenrate und Framerate benötigt werden. Beide Werte schwanken je nach Systemauslastung. Die Funktionen zur Berechnung der Werte werden erst an späterer Stelle definiert:

219 function resetxmlcount() {
220   if(mySocket.connected){
 
222     this.lastmeasuredxmlps = this.xmlcount;
223     this.xmlcount = 0;
224   }
225 }
 
252 function resetfpscount() {
 
255   this.lastmeasuredfps = this.fpszaehler;
256   this.fpszaehler = 0;
257 }
258
259 FPSintervalId = setInterval(this, "resetfpscount", 1000);
260 XMLintervalId = setInterval(this, "resetxmlcount", 1000);
 
268   this.onEnterFrame = function(){
 
270 this.fpszaehler++;
271
272 }

Im Anschluss werden einige Variblan deklariert, die im XML bzw. FLOSC-Abschnitt bereits aufgetaucht sind:

46 // Anzahl der Positionen die zwischen zwei exakten XML-Informationen "dazuerfunden" werden. Cursorrate: (interpolationsmenge + 1) * lastmeasuredxmlps
47 var interpolationsmenge:Number = 5;
48 var interpolationszaehler:Number = 0;
49 var interpolationsintervallid:Number = null;
50
51 var myxmlmouse:Point = new Point(0, 0);
52 var lastxmlmouse:Array = new Array();
53 var xmlovervorher:Boolean = false;
54 var IPaddress:String = "127.0.0.1";
55 var port:String = "3000";

Der x-Wert des Abstoßpunktes für die weiße Kugel sowie der x-Wert der vordersten Kugel des Spielkugel-Dreiecks werden festgelegt:

57 // Kugelaufbau
58 var linkespielkugelx:Number = 737;
59 var dreieckmittey:Number = 384;

Im Anschluss erfolgt die Definition sämtlicher Funktionen. Bisher wurde praktisch keine sichtbare Aktion getätigt. Die Funktion drawRectangle() ist eine Funktion, die ein Rectangle-Objekt in einen angegebenen MovieClip zeichnet. Diese Funktion dient lediglich Debugzwecken, beispielsweise zum Darstellen der Kollisionszonen.

61 function drawRectangle(rechteck:Rectangle, panel:MovieClip) {
62   with(panel){
63     lineStyle(5, 0xFFFFFF, 50);
64     beginFill(0xcccc, 50);
65     moveTo(rechteck.left, rechteck.top);
66     lineTo(rechteck.right, rechteck.top);
67     lineTo(rechteck.right, rechteck.bottom);
68     lineTo(rechteck.left, rechteck.bottom);
69     lineTo(rechteck.left, rechteck.top);
70     endFill();
71   }
72 }

Da in Flash jedes Objekt auch als assoziatives Array interpretiert werden und dadurch auch mit Hilfe einer Schleife durchsucht werden kann, können sämtliche Kugelobjekte anhand ihrer Klassenzugehörigkeit erkannt und in einem Array gesichert werden. Dies geschieht voll dynamisch, so dass das Billardspiel problemlos mit weniger Kugeln gespielt werden kann.

74 function buildKugelArray(){
75   var temparray:Array = new Array();
76   for (var i in this) {
77     if(this[i] instanceof Kugel){
78       temparray.push(this[i]);
79     }
80   }
81   return(temparray);
82 }

Um das Billardspiel komplett dynamisch zu gestalten, wurde schließlich noch die Positionierung aller Spielkugeln in Dreiecksform in Abhängigkeit von KUGELGROESSE implementiert.
Der Algorithmus ist das Resultat von viel Knobelei und ist nicht allzu bedeutend. Die x-Position des Dreiecks wurde zu Beginn definiert und liegt genau auf Höhe der mittleren "Diamant"-Markierung auf jeder Tischhälfte, die in den Rand-Simsen eingelassen ist. Die y-Position ist durch die vertikale Mitte des Bildschirms festgelegt.

84 function positioniereKugeln(linkespielkugelx, dreieckmittey){...}

Nachfolgend werden sämtliche zur XML-Verarbeitung und -Interpolation nötige Funktionen definiert. Dies wurde aber im XML/FLOSC-Abschnitt bereits ausreichend dokumentiert.
Nun schreitet das Programm endlich zur Tat. Zunächst werden die Kugeln auf der Bühne instantiiert.

automatische Positionierung der Billardkugeln anhand mathematischer Systematisierung des Auflegebereichs
239 this.attachMovie("weissekugel", "weissekugel", this.getNextHighestDepth(), {position:new Point(287, 384), kugelwert:0});
240
241 positioniereKugeln(linkespielkugelx,dreieckmittey);

Zu Debugzwecken können zur Darstellung der XML-Cursor-Position 2 Kreuze eingeblendet werden. Das rote Kreuz wird mit allen "echten" XML-Positionen versetzt, das grüne Kreuz stellt den interpolierten Cursor dar, der letztendlich auch verwendet wird.

Debug-Mauskreuz-Cursor
246 this.attachMovie("mauskreuz", "mauskreuz", this.getNextHighestDepth(), {_x:100, _y:100});
247 this.attachMovie("mauskreuzgruen", "mauskreuzgruen", this.getNextHighestDepth(), {_x:0, _y:0});

Damit wären alle optischen Details abgedeckt. Nun folgen die Initialisierungen aller unsichtbaren Elemente:

262 this.kugelobjekte = this.buildKugelArray();
263
264 var meingamemanager:GameManager = new GameManager(this.iface);
265
266 var meinchecker:Kollisionschecker = new Kollisionschecker(SPIELFLAECHE, KOLLISIONSSPIELFLAECHE, MITTELLOECHERKOLLISIONSSPIELFLAECHE, LOCHZONELO, LOCHZONERO, LOCHZONERU, LOCHZONELU, KUGELGROESSE, kugelobjekte, UPDATEINTERVALL, BOUNCE, meingamemanager);
267
268 this.onEnterFrame = function(){
269   meinchecker.check();
270   this.fpszaehler++;
271   this.gamemanagerfeld.text = meingamemanager.ausgabe;
272 }
273
274 var myXMLMouseDispatcher:XMLMouseDispatcher = new XMLMouseDispatcher();
275
276 myXMLMouseDispatcher.addEventListener("onXMLOver", weissekugel);

Damit ist alles fertig initialisiert. Durch Aufruf von connect() loggt sich das Spiel am FLOSC-Server ein und die Datenübertragung beginnt. Der Login-Vorgang wird nur ein einziges mal beim Programmstart durchgeführt. Aus diesem Grund muss FLOSC bereits laufen, wenn das Spiel gestartet wird, da sonst kein Login zustandekommt.

278 connect();

ToDo-Liste und Fazit zum Programm

Wie nun ersichtlich ist, wird bei jedem Frame (this.onEnterFrame()) die Methode check() der Kollisionscheckerklasse ausgeführt. Diese ziemlich rechenaufwändige Funktion überprüft in jedem Durchlauf alle Kugeln auf Kollisionen. Die CPU-Last des Spiels ist dementsprechend recht hoch, was mit Sicherheit noch optimiert werden könnte.

Die Schwankungen in der Rechenlast äußern sich teilweise als Ruckler. Um diese Schwankungen möglichst gering zu halten, könnte z.B. die Bildverarbeitung auf ein anderes System ausgelagert werden, da ja der Koordinatenstream intern ohnehin als Netzwerkstream behandelt wird.

Was vielleicht bei der Beschreibung der Löcher und der GameManager-Klasse aufgefallen sein mag: Es wird bei jedem Einlochvorgang die Lochnummer mit übergeben. Sinn davon ist die Kneipenregel, nach der die schwarze 8 in das dem Loch, in dem die letzte eigene Kugel versenkt wurde, diagonal gegenüberliegende Loch versenkt werden muss. Diese Überprüfung wird aber nicht durchgeführt. Die Implementation davon wäre sicher nicht allzu aufwändig, zumal ja das Grundgerüst praktisch schon steht.

An manchen Stellen im Code werden unnötig globale Variablen verwendet, was zwar während des Programmierungsprozesses sehr komfortabel erschien, aber weiterem Wachsen des Projekts sehr schnell zu ungewünschten Nebeneffekten führen könnte, falls beispielsweise an zwei Stellen unsynchronisiert in die globale Variable geschrieben wird. Das Konzept des "information hiding" als wichtiger Bestandteil der Objektorientierung könnte und sollte also noch konsequenter umgesetzt werden. Die Objektorientierung an sich hätte ebenfalls noch an mehreren Stellen verstärkt umgesetzt werden können. Aus dem Fundus der Funktionen und Klassen des Malprogramms konnte sehr viel übernommen werden, was sehr viel Arbeit ersparte.

Oberflächendesign

Anforderungen an die Benutzeroberfläche

Bevor mit der Gestaltung und der Programmierung der Benutzeroberfläche begonnen werden konnte, war festzustellen, welche Eigenschaften das Billardspiel später aufweisen soll. Daher war es notwendig, die Anforderungen an Design und Funktionalität klar zu beschreiben und zu definieren.

Design

Spielgefühl & Einstieg:
Das Hauptziel des Designs der Spieloberfläche besteht darin, dem Anwender ein möglichst reales Spielgefühl zu vermitteln. Auf diese Weise soll ihm der Einstieg in die Benutzung des interaktiven Systems leichter fallen, sodass eventuell auftretende Hemmschwellen schneller überwunden werden können.

Assoziation mit realem Pool-Billard:
Auch soll der Spieler die projizierte Abbildung auf den ersten Blick mit einem Pool-Billardspiel assoziieren. Da die Mehrheit der Benutzer mit den Grundlagen von Billard vertraut ist, kann daher in den meisten Fällen sofort mit dem Spielen begonnen werden.

Ergonomie:
Weiterhin ist es wichtig, dass ergonomische Aspekte wie die leichte Ablesbarkeit aller relevanten Informationen berücksichtigt werden. So sollen beide Spieler unabhängig von deren Standpunkt im Raum sämtliche Inhalte des Spielfortschrittes problemlos erfassen und ablesen können.

Funktionalität

Verwendung durch zwei Spieler:
Da Pool-Billard eine Sportart ist, die meist von zwei Personen ausgeführt wird, ist die Funktionalität der Anwendung auf die Benutzung durch zwei Spieler ausgelegt.

Learning by doing:
Kenntnisse über die Spielregeln sollen nicht zwingend erforderlich sein, da ein grundlegendes Regelwerk im Laufe des Spieles über das Interface vermittelt werden wird.

Spielverwaltung:
Desweiteren muss das Interface sämtliche Aktionen beider Spieler auswerten können. Auf Basis dieser Daten soll das Interface die Spielverwaltung eigenständig übernehmen.

Modularität:
Die Spieloberfläche soll anhand minimaler Informationseingaben alle Anweisungen ausführen können, damit die einzelnen Programmteile bestehend aus Spielphysik und Oberfläche weitgehend unabhängig voneinander operieren können.

3D Billardtisch & Kugeln

Vorbereitung

Die Aufgabe, ein realistisches Spiel zu vermitteln wird zum größten Teil über das Aussehen des Billardtisches abgehandelt, da dieser ein entscheidendes Bezugselement zum Pool ist.
Folgende optische Merkmale muss der Billardtisch aufweisen, um alle gesetzten Anforderungen zu erfüllen:

  • korrekte Formen und Maße nach internationalen Normen
  • stimmige Musterung und Farbgebung in Anlehnung an reale Billardtische
  • optische Details, welche nicht durch Normen bezüglich der Formgebung festgelegt sind
  • Individualisierung der Tischform
  • realistisches Lichtverhalten: Spiegelung oder Schattenwurf; gerichtetes und diffuses Licht

Gründe für die Umsetzung in 3D
Mittels zweidimensionaler Abbildungen lassen sich räumlich ausgedehte Objekte wie ein Billardtisch nur in abstrahierter Form durch die gezielte Setzung von Licht und Schatten annähern. Die Möglichkeit, räumliche Tiefe über verkürzte Kanten und deren Abschrägung/Verwinklung darzustellen ist hier nicht gegeben, da eine Aufsicht auf den Billardtisch angedacht ist.
Im Gegensatz zu zweidimensionalen Objekten besitzt ein 3D Objekt neben Höhe und Breite auch Tiefeninformation. Wird ein zweidimensionales Bild auf Basis eine s räumlichen Modells erstellt, so basieren Lichtsetzung, Schattierung usw. auf der Beeinflussung der sich überlagernden Objektbereiche in z-Richtung. Trifft nun Licht auf einen dieser Bereiche, so wirft dieser einen Schatten, dem physikalisch und mathematisch fundierte Formeln zu Grunde liegen. Der Vorteil des 3D Objektes besteht also darin, dass die Entscheidung bezüglich Schattenwurf, Spiegelung usw. auf Seiten des Modeling-Tools liegt. Dies schließt Ungenauigkeiten und kleine Fehler aus, die bei der Lichtsetzung von Hand zwangsläufig entstehen würden.

Auswahl des geeigneten Modellierungsprogramms
Alle Arbeiten am Billardtisch wurden mit dem 3D-Modellierungs- und Animations-Programm Cinema4D (C4D) durchgeführt.

Realisierung

Schritt 1: Grundmodell
Der
BillardPro of Tisch Grundform.png
erste Schritt bei der Modellierung des Billardtisches war die Erstellung eines einfachen 3Dmodells in Cinema4D, auf dessen Basis weitere Arbeiten durchgeführt werden konnten. Dieses vereinfachte Modell setzte sich zusammen aus der Spielfläche, den Banden und einem Rahmen.

Verwendung von Grundobjekten:
Die Wahl der Modellierungsmethode fiel hierbei auf die Nutzung geometrischer Grundkörper, da neun Würfeln ausreichten, die komplette Basis des Tisches zu erstellen. Somit war es durch die Verwendung bereits existierender Formen nicht notwendig, jedes einzelne Element von Hand aufzubauen. Durch die jeweilige Skalierung dieser Würfel auf die benötigten Maße nach internationaler Norm konnten alle Ausgangsformen des Spielfelds, der vier Banden und des gesamten Rahmens modelliert werden.

Eigenschaften von Grundkörpern:
Grundkörper aus der Objektbibliothek von Cinema4D besitzen artspezifische Merkmale, über die auf unkomplizierte Weise Änderungen in Bezug auf Größe, Radius, Durchmesser sowie Art und Anzahl der Segmente (Polygonzahl) vorgenommen werden können. Grundobjekte haben jedoch den Nachteil, dass es keine Möglichkeit gibt, ihre Polygonpunkte einzeln zu beeinflussen, was Deformationsarbeiten auf der Basis von Grundkörpern ausschließt. Veränderungen, die das ganze Objekt betreffen sind hingegen durchführbar. Dazu zählen Dinge wie Skalierung, Verformung, Änderung der Rotationslage oder eine Positionsverschiebung im Raum.

BillardPro of Grundobjekt Kugel.png

Weitere Bearbeitung: Polygonobjekte:
Da Grundkörper nur eingeschränkte Möglichkeiten bieten, Verformungsarbeiten durchzuführen galt es nun, Polygonobjekte zu erstellen. Durch die Option des Kontextmenüs "Grundobjekt konvertieren" wurden nun alle Ausgangskörper in Polygonobjekte (PO) umgewandelt.

BillardPro of Kugel GO.png
BillardPro of Kugel PO.png

POs sind für detaillierte Modellierungsarbeiten sehr gut geeignet, da sie über die Einflussnahme auf deren Grundstrukturen (Polygone, Kanten, Eckpunkte) beliebig verformt und erweitert werden können. Die Konvertierung in Polygonkörper ermöglichte nun eine objektinterne Veränderung der Banden und des Rahmens. So wurden alle sich überschneidende Flächen dahingehend verändert, dass sie unter einem 45° Winkel bündig miteinander abschließen.
Dieser Arbeitsschritt musste durchgeführt werden, um Störungen entgegenzuwirken, die durch die deckungsgleiche Überlappung zweier Polygonebenen auftreten. Diese Bildfehler traten auf, weil nicht festgelegt war, welche der beiden identischen Flächen angezeigt werden soll. So waren je nach Betrachtungswinkel flackernde Streifen auf der Oberfläche zu sehen, welche stark an den Moire-Effekt erinnern.
Durch die Entfernung einzelner Polygonflächen wurden die innenliegenden überschüssigen Seiten des Rahmens und der Banden entfernt. Dies geschah aus Gründen der Sauberkeit und zur Vorbeugung von eventuell auftretenden Bildfehlern, die durch überflüssige Modellteile hervorgerufen werden können.
Die Abschrägung der Banden auf die gewünschte Keilform geschah mittels der Verschiebung einzelner Polygonpunkte in z-Richtung. Mit der Aufbringung einer vorläufigen Texturierung wurden die Arbeiten an der Grundform des Billardtisches abgeschlossen.

Schritt 2: Taschen des Billardtisches

In der zweiten Phase der Modellierung erhielt das Billardspielfeld die Taschen an den seitlichen Banden und den Ecken. Für diesen Part eignete sich das sogenannte Bool'sche Objekt, da es eine Vielzahl der benötigten Arbeitsschritte automatisch durchführt.

BillardPro of taschen.png

Exkurs: Boolean

Die Bool'sche Cinema4D-Funktion ist ein rechenintensives Verfahren zur Modellierung komplexer Körper durch die Kombination zweier Objekte bzw. Objektgruppen. Über das sogenannte Bool-Objekt stehen vier Bool'sche Operationen zur Verfügung: Plus, Minus, Geschnitten und Ohne.

Definition

Das Bool-Objekt (BO) ist viel mehr ein funktionsbeladener Objektcontainer als ein Objekt. So hat es neben der eigenen Position im 3D Raum weder räumliche Eigenschaften, noch eine Oberflächentextur.

Auswirkungen auf untergeordnete Objekte:
Polygonobjekte/Objektgruppen, die dem Container zugewiesen werden erhalten zusätzlich zu ihren eigenen Koordinaten auch die des BO auf der Hauptebene. Auch Texturen und Animationen (Explosion, Verschiebung, Verformung, Skalierung, etc.) wirken sich über das Bool-Objekt auf die untergeordneten Körper aus.
Zu beachten ist aber, dass alle Eigenschaften, die dem Bool'schen Objekt zugeordnet werden keinen direkten Einfluss auf die Körper innerhalb der Objektebene haben.

Beispiel Koordinaten:
Verschiebt man das Bool-Objekt mit den Koordinaten (0, 0, 0) um

  • 150 in x-Richtung
  • 75 in y-Richtung
  • 25 in z-Richtung

so ändern sich die Koordinaten der beiden untergeordneten Polygonkörper PK1 (200, 500, 100) und PK2 (1000, 1500, 3750) auf

  • Hauptebene
    • PK1(350, 575, 125)
    • PK2(1150, 1575, 3775)
  • Objektebene
    • PK1(200, 500, 100)
    • PK2(1000, 1500, 3750)

Gleiches gilt auch für Texturen
Bool'sches Objekt = Farbe: blau

  • PK1 = Muster: rot-weiß-gestreift
  • PK2 = Video

 

  • Hauptebene
    • PK1 = Farbe: blau
    • PK2 = Farbe: blau
  • Objektebene
    • PK1 = Muster: rot-weiß-gestreift
    • PK2 = Video

Funktion des Bool-Objektes

Das BO besitzt vier Operatoren, die sich direkt auf die zugeordneten Objekte/Objektgruppen auswirken.
Durch jede Bool-Operation wird ein Objekt A mit einem Objekt B so verschmolzen, dass daraus der Zustand C (boolsches Objekt) entsteht. Körper A übernimmt durch diesen Vorgang alle Form- und Textur-Merkmale von B an den Schittflächen.
Die Operatoren entsprechen im Prinzip verschiedenen Funktionen, an die zwei Objekte übergeben werden. Die Besonderheit ist, dass die Bool-Funktionen in Echtzeit verarbeitet werden. Verschiebt man also ein Objekt innerhalb des Bool-Containers, so werden alle Positionswerte sofort berechnet. Das hat den Vorteil, dass Änderungen noch während der Ausführung angezeigt werden. Nachteilig ist jedoch die hohe Rechenlast, die während des Änderungsprozesses anfällt. Dies führt zu einer sinkenden Frame-Rate, die sich durch ein ruckelndes Bild oder in Extremfällen sogar durch den Absturz des gesamten Systems bemerkbar macht.

Die vier Bool-Operatoren

C = A plus B
C = A minus B
C = A geschnitten B
C = A ohne B


Vor- und Nachteile des Bool-Objektes

Vorteile:
Im Vergleich zur herkömmlichen Modellierungsmethode über Polygonpunkte bietet das Bool Objekt bei geringerem Aufwand schnellere Ergebnisse. So lassen sich in kurzer Zeit sowohl geometrische Grundkörper, als auch detaillierte Polygonobjekte zu komplexen Objekten verbinden. Über Objektcontainer wie das sogenannte Nullobjekt ist das bool'sche Verfahren ebenso auf komplette Objektgruppen anwendbar.

Nachteile:
So viele Vorteile die Bool-Methode mit sich bringt, so viele Nachteile hat sie auch. Allem voran wirkt sich das Verfahren wegen den Echtzeitberechnungen der Schnittpunkte beider Körper drastisch auf die Rechenleistung des Modeling-PCs aus, die diese ständig durchgeführt werden, auch wenn das Bool-Objekt selbst nicht bearbeitet wird. Neben der hohen Prozessorlast, welche ein zügiges Arbeiten nahezu unmöglich macht kommt es auch häufig zu Systemabstürzen, wenn eine bestimmte Anzahl von Bool'schen Objekten überschritten wird.
Auch der Export in andere 3D Formate bereitet Probleme, da der Bool-Operator ein Cinema4D-eigenes Mobellierungstool ist und von anderen 3D-Programmen oftmals gar nicht oder nur fehlerhaft erkannt wird.
Eine weitere Fehlerquelle ist die ungenaue Unterteilung der Flächen des resultierenden Körpers/Zustands. (siehe Grafik: C = A geschnitten B) Da der Bool-Operator willkürlich Punkte setzt sind die Ergebnisse unvorhersehbar und eignen sich kaum für detaillierte 3D Arbeiten. Ein Nacharbeiten ist in solchen Fällen dringend erforderlich.

Viele der Nachteile lassen sich aber aufwiegen, indem die Bool'schen Objekte Schritt für Schritt und nicht gleichzeitig bearbeitet werden. Sobald die Arbeiten an einem BO abgeschlossen sind, sollte es in ein Polygonobjekt umgewandelt werden bevor andere Modellierungtätigkeiten angegangen werden, da Polygonobjekte im Vergleich zum BO deutlich weniger Systemresourcen in Anspruch nehmen, was ein ruckelfreies Modeling begünstigt. Zudem lassen sich die in Objekte konvertierten Bool-Container über die Eckpunkte ihrer Polygone sehr präzise verformen und erweitern.

Fazit

Das Bool-Objekt stellt einen guten Kompromis aus Zeitersparnis und Modelling-Genauigkeit dar.


Fortsetzung – Schritt 2: Taschen des Billardtisches

Anwendung des Boolschen Objektes:
Für die Löcher des Billardtischs wurde ein Boolsches Objekt mit dem Operator
C = A minus B
benötigt.

Objekt A:
Die Grundform des Billardtisches diente als das erste der beiden Objekte und wurde innerhalb des Bool-Operators platziert. Da der Grundkörper zum derzeitigen Zeitpunkt aus mehreren Polygonobjekten/Würfeln bestand, mussten diese in einem Objektcontainer, dem sogenannten Null-Objekt gruppiert werden. Nur so war sicherzustellen, dass das Bool-Objekt bei der Subtraktion alle Elemente des Billardtisches mit einbezieht.

Objekt B:
Eine Objektgruppe bestehend aus mehreren Zylinder bildete das Objekt B. Nach der Positionierung der zylindrischen Körper an den entsprechenden Koordinaten im dreidimensionalen Raum wurde die Objektgruppe B an die zweite Stelle im Bool'schen Container gesetzt.

BillardPro of Tisch Bool 1N.jpg BillardPro of Tisch Bool 2N.jpg BillardPro of Tisch Bool 3N.jpg

Durch die Platzierung beider Objekte im bool'schen Operator verschwand augenblicklich die Form des Körpers B.

BillardPro of Tisch Bool 1B.jpg
BillardPro of Tisch Bool 2B.jpg
BillardPro of Tisch Bool 3B.jpg

Schnittflächen der Formen A und B erschienen nun als Aussparung im Objekt A. Die Oberfläche der Seitenwände jeder Tasche erhielt durch diesen Vorgang alle Textureigenschaften des Objektes B. Dazu zählen Faktoren wie Transparenz, Spiegelung, Farbe, Muster oder Oberflächenstrukturen.

Der nächste Arbeitsschritt bestand darin, das Bool-Objekt für die nachfolgende Bearbeitung in ein Polygonobjekt zu konvertieren. Da das Bool-Objekt ungenaue Ergebnisse liefert wurden die Taschen per Hand nachträglich weiter bearbeitet. So waren überschüssige Polygone zu löschen, Punkte zu entfernen oder hinzuzufügen und die Koordinaten aller Elemente auf gerade Zahlenwerte zu setzen.

Durch die wiederholte Anwendung der Bool'schen Methode mit anderen Formen/Objekten erhielten die Banden an den Löchern die gewünschten Schrägen.

Schritt 3: Abschließende Arbeiten

Da der Billardtisch einen möglichst realistischen Eindruck vermitteln soll, waren im dritten und letzten Schritt Feinarbeiten durchzuführen.

  • Messingbeschläge an den Tischecken inklusive Kunststoff-Stoßfänger
  • Bearbeitung des Rahmens
  • endgültige Texturen nach dem Vorbild realer Pool-Tische
  • Lichtsetzung

Messingbeschläge an den Tischecken:
Für die Umsetzung der Messingbeschläge standen zwei Alternativen zur Auswahl. Möglichkeit Nr. 1 bestand darin, eine gemusterte goldfarbene Textur auf die Rahmenenden des Billardtisches aufzubringen. Dies hätte den Vorteil gehabt, dass aufwändige Modellierungsarbeiten an den Ecken nicht hätten durchgeführt werden müssen, um diese optisch vom restlichen Rahmen abzuheben.
Eine Extrudierung der Ecken war jedoch die für den Billardtisch besser geeignete Alternative zur einfachen Texturierung der Rahmenenden. Zwar war dies mit einem deutlichen Mehraufwand an Zeit und Arbeit verbunden, sorgte dafür aber für qualitativ bessere Ergebnisse, wenn es um die realistische Anmutung des Tisches geht, da sich die Erhöhung der Rahmenecken positiv auf das Verhalten von Texturmerkmalen wie Glanz, Spiegelung und Schattenwurf auswirkte.

Drahtgittermodell des Tisches
Da der Aufwand viel zu groß gewesen wäre, aus jeder Ecke des Billardtisches diese Messingbeschläge herauszumodellieren, wurde der Rahmen zunächst in 6 Bereiche unterteilt. Auf diese Weise musste nur eine Ecke (Bereich 1) und eine Längsbande (Bereich 2) bearbeitet werden. Nach Abschluss der Modellierung wurde das Eckteil kopiert und nach der entsprechenden Rotation um 90°, 180° oder 270° an die entsprechende Position des Tisches platziert. Analog wurde mit der Seitenbande verfahren.

Zusätzlich zu den Messingbeschlägen wurden im gleichen Arbeitsschritt auch die Kunststoff-Stoßfänger der Taschen modelliert. An den Ecklöchern sind diese Bestandteil der Messingbeschläge.Die Kunststoffrahmen der Mitteltaschen wurden durch die Platzierung und Verschiebung zusätzlicher Objektpunkte aus dem Rahmen herausmodelliert.

Bearbeitung des Rahmens:
Die Feinarbeiten am Rahmen gestalteten sich im Gegensatz zu den Messingteilen als recht einfach. Neben der Abschrägung von wenigen Grad waren dem Rahmen lediglich Markierungen in Form von verzerrten/gestauchten Zylindern an den entsprechenden Positionen hinzuzufügen.

Texturen: Musterung und Farbgebung:
Nachdem sowohl der Rahmen, als auch die Messingbeschläg mit einer Fase im 45°-Winkel versehen wurden, konnte mit den Texturarbeiten begonnen werden.

Eigenschaften von Texturen:
Über Texturen werden Oberflächen von Polygonkörpern sogenannte Materialeigenschaften zugewiesen. Diese Eigenschaften setzen sich aus verschiedenen Bestandteilen wie Farbe, Spiegelung usw.
Texturen erfüllen 2 Funktionen:

  • Sie geben vor, wie ein 3D-Körper aussieht und wie dessen Oberfläche auf äußere Einflüsse wie Licht und andere Objekte reagiert.
  • Texturen bieten zudem die Möglichkeit, Polygonobjekte einzusparen.

Beispiel:
Anstatt jede einzelne Windung eines Schraubengewindes zu modellieren kann man einen Zylinder mit einer gestreiften Textur versehen, was in den meisten Fällen völlig ausreichend ist, um diese Schraube realistisch darzustellen.

BillardPro of Tisch Ecke Wireframe.jpg
BillardPro of Tisch Ecke Gerendert.jpg

Rahmen, Messingbeschläge und Kunststoff-Stoßfänger waren jeweils mit unterschiedlichen Texturen zu versehen.

BillardPro of tabelle texturen.png

Da sich die Zuweisung von Oberflächeneigenschaften stets auf das gesamte Objekt bezieht, die sechs Rahmensegmente aber jeweils aus einem zusammenhängenden Polygonobjekt bestehen, galt es vor der Aufbringung von Texturen Selektionen der verschiedenen Polygongruppen zu erstellen. Hierfür wurden alle benötigten Polygone des jeweiligen Abschnittes ausgewählt und mit der Funktion "Selektion einfrieren" fixiert. Die gewählte Polygongruppe erscheint daraufhin als sogenanntes "Polygon Selektion Tag" in Form eines kleinen roten Dreiecks im Objektmanager. Um dem Polygon Selektion Tag eine Textur zuweisen zu können, musste es im nächsten Arbeitsschritt mit einem Namen versehen werden. Dieser Name dient der Identifizierung des Objektes und sollte eindeutig vergeben werden, da sich namentlich zugeordnete Einflüsse auf alle gleich benannten Selektionen auswirken.
Abschließend erhielt jedes Eckteil die drei verschiedenen Texturen. Durch die Zuweisung der Selektions-Namen wurden die Oberflächenmerkmale den einzelnen Bestandteilen des Objekts zugewiesen. Bei den Banden genügten zwei Materialeigenschaften, da diese nur aus den Kunststoff-Stoßfängern und dem Rahmen bestehen.

Den Abschluss des dritten und letzten Arbeitsschrittes bildete die Setzung der Lichtquellen. Ziel war es, das Spielfeld, die Banden und den Rahmen optimal auszuleuchten, sodass alle Materialien entsprechend ihrer Eigenschaften möglichst realistisch zur Wirkung kamen. Zudem war es wichtig, dass sich die Banden trotz gleicher Oberfläche deutlich vom Spielfeld abheben. Die Wahl fiel zugunsten eines Punktlichts, das mittig über dem Billardtisch angebracht wurde. Dieses strahlte nicht sichtbares Licht ohne Noiseverhalten in alle Richtungen aus. Daraus ergab sich eine diffuse Lichtstimmung mit weichen Schattenkanten.

Die Modellierung der Spielkugeln gestaltete sich hingegen denkbar einfach. So wurden Kugel-Grundkörper aus der Objektbibliothek von Cinema4D geladen und mit Texturen versehen.

BillardPro of Polygone freigestellt.png BillardPro of Interface Spielerkugel.jpg

Interface

Umstieg von Ogre3D auf Flash

Ogre3D
Zu Beginn der Arbeiten war es angedacht, die gesamte Programmierung der Spieloberfläche auf Basis von C++ über die OpenSource 3D-Engine namens Ogre3D (Object-Oriented Graphics Rendering Engine) zu realisieren. Dieser Ansatz wurde aus verschiedenen Gründen jedoch nach kurzer Zeit bereits wieder verworfen.
So erwies sich die Programmierung über Ogre in der zu Verfügung stehenden Zeit als zu aufwändig, da Grundlagen zur objektorientieren Programmierung zwar in Java, nicht aber in C++ vorhanden waren.
Des weiteren gestaltete sich der Import der benötigten 3D Objekte des Billardtisches, der Kugeln und des Interfaces von Cinema 4D in die Engine von Ogre als problematisch. Objekte können von C4D nur über Plugins von Drittanbietern an Ogre3D exportiert werden, da dieses auf die Verwendung von Maya- und 3D Studio MAX- Modellen ausgelegt ist.
Wichtigestes Argument gegen die Umsetzung mit Ogre war aber der fragwürdige Nutzen einer auf Echtzeit basierenden 3D-Oberfläche, da lediglich eine vertikale Projektion des Spiels auf einen Tisch angedacht war.

Flash
Für die Realisierung über Flash und Actionscript sprachen hingegen viele Argumente.
So besaßen beide Mitglieder des Programmierungs & Design-Teams zu Beginn der Projektarbeiten bereits Erfahrung mit dem Werkzeug, was eine Einarbeitung in die Programmumgebung mit dem verbundenen Zeitaufwand überflüssig machte.
Weiterhin besitzt Flash programminterne Funktionen zur Verarbeitung von XML-Daten, was die Schaffung einer Schnittstelle mit EyesWeb üder einen Flosc-Server stark vereinfachte.
Entscheidend für die Wahl waren auch die auf Interaktion und Animation ausgelegten Programmstrukturen von Flash. Über Actionscript stehen eine Vielzahl von objektorientierten Funktionen zur Verfügung, die sich über Instanzen auf Objekte in der Bibliothek oder auf der Bühne anwenden lassen. Zudem ermöglicht die Verwendung von Klassen und Movieclips einen Modularen Aufbau des Programms.

Interface – Design

Da die Erfüllung aller Anforderungen an die Spieloberfläche in Bezug auf die Funktionalitäten auf Seiten des Interfaces liegt, ist es entscheidend das Aussehen mit der zugehörigen Programmierung so in Verbindung zu setzen, dass ein übersichtliches und einfach zu verstehendes Verwaltungssystem entsteht.

Nachdem die Entscheidung gefallen war, das Spiel – mit Ausnahme der räumlichen Modelle der Kugeln und des Billardtisches – mit Flash in 2D umzusetzen, konnte mit der Planung des Interface-Layouts begonnen werden.
Zu Beginn dieser Planungsarbeiten war die Arbeit am Billardtisch bereits abgeschlossen und so richtete sich die Gestaltung des Interfaces in Bezug auf Form und Farbigkeit nach dem Aussehen des aus Cinema4D importierten Photoshop-Bildes des Pooltisch-Modells.

Positionierung
Die Abbildung des Billardtisches nimmt Aufgrund der geringeren Abmessung von 1024 x 568 Pixeln im Vergleich zur Beamerauflösung (1024 x 768) nicht den gesamten Raum der Projektion in Anspruch.
Durch die Platzierung des Billardtisches in der Mitte der Projektionsfläche boten die daraus resultierenden freien Flächen im Bild ausreichend Platz für zwei Interfaceleisten. Der Zweck dieser Aufteilung des Interfaces war der, dass mit der optischen Trennung auch eine Trennung der angezeigten Informationen erzielt wurde. So verfügt jeder der beiden Spieler über einen Interaktionsbalken, auf dem alle ihn betreffenden Spielinhalte abgebildet sind.

BillardPro of interface00.jpg

Innerer Aufbau
Um die gesetzten funktionalen Anforderungen zu erfüllen, mussten die informationsgebenden Elemente in übersichtlicher und klar strukturierter Form auf den einzelnen Interfacebalken platziert werden. Auch galt es, der Wechselwirkung des gesamten Interfaces zur restlichten Spieloberfläche bestehend aus dem Spielfeld Beachtung zu schenken. So durfte das Interface nicht vom Spielgeschehen ablenken und sollte doch so auffällig sein, dass der Nutzer das Signalsieren eines Spielerwechsel oder Spielendes nicht übersieht.

Beide Interfacebalken wurden in drei Bereiche aufgeteilt:

BillardPro of tabelle interface.png

 

Interface – Funktionalität

Systementwurf
Bevor mit der Implementierung des Interfaces begonnen werden konnte, musste zunächst der Funktionsumfang des Systems beschrieben werden. Die einzelnen Funktionen orientierten sich dabei an den Aufgaben, die das Interface erfüllen musste.
Da sich das Interface um alle Spielabläufe kümmert, die in Kraft treten, nachdem ein Spielzug erfolgreich oder erfolglos abgeschlossen wurde, war zu ermitteln, welche Aufgaben vom System autonom übernommen werden müssen, um diese Spielabläufe auch steuern zu können. Somit galt es, fiktiv den Spielablauf eines Billardspieles nachzustellen und die Ereignisse zu sondieren, die nach Abschluss eines Spielzuges eintreten.

Spielablauf


Ausgangsposition

BillardPro pr billard.jpg

Das Spiel beginnt.

  • Spieler 1 ist an der Reihe
  • keinem der Spieler ist kein Kugeltyp zugewiesen
  • beide Interface-Fenster sind leer, da bisher keine Kugel eingelocht wurde

1. Kugel Versenkt

BillardPro of interface01.jpg

Spieler 1 kann erfolgreich die erste Kugel versenken. Die versenkte Kugel hat den Wert 1 und ist Kugeltyp "voll".

  • Spieler 1 bleibt wegen des erfolgreichen Spielzuges weiterhin an der Reihe
  • Spieler 1 werden alle sieben "vollen" Kugeln zugewiesen
  • Spieler 2 werden die sieben "halben" Kugeln zugewiesen
  • die versenkte Kugel rollt in das Fenster von Spieler 1

Foul: Kugel des Gegners versenkt

BillardPro of interface02.jpg

Spieler 1 begehnt versehentlich ein Foul indem er eine gegnerische Kugel versenkt

  • Spieler 1 wird auf seinen Fehler aufmerksam gemacht
  • ein Spielerwechsel wird vollzogen
  • die versenkte Kugel rollt in das Interfacefenster von Spieler 2
  • Spieler 2 ist nun an der Reihe

Weiterer Spielablauf

BillardPro of interface03.jpg

Sowohl Spieler 1, als auch Spieler 2 versenken entweder erfolgreich Kugeln oder verpassen dieses Ziel.

  • wird die korrekte Kugel versenkt, so bleibt der Spieler an der Reihe; die Kugel rollt in das jeweilige Interface-Fenster
  • gelingt es dem Spieler nicht, eine Kugel zu versenken, so wird ein Spielerwechsel vollzogen
  • versenkt ein Spieler die weiße Kugel, so wird diese Aktion als Foul gezählt und ein Spielerwechsel wird durchgeführt

Spielende 1: 8 wurde zu früh versenkt

BillardPro of interface04.jpg

Spieler 1 versenkt mit der 8 die 15. Kugel des Tisches zu früh und verliert somit das Spiel .

  • Spieler 1 hat verloren
  • Spieler 2 hat gewonnen
  • Der Endstand des Spiels wird dauerhaft angezeigt
  • Spieler 1 wird auf seinen Fehler hingewiesen
  • die versenkte Kugel 8 rollt in das Fenster von Spieler 1
  • Das Spiel ist beendet

Spielende 2: Erfolgreicher Abschluss des Spiels

BillardPro of interface05.jpg

Spieler 1 gelingt es, sowohl alle 7 Kugeln seines zugewiesenen Kugeltyps "voll" und abschließend auch die Kugel 8 zu versenken.

  • Spieler 1 hat gewonnen
  • Spieler 2 hat verloren
  • Der Endstand des Spiels wird dauerhaft angezeigt
  • Spieler 1 wird auf seinen Sieg hingewiesen
  • Die versenkten Kugeln rollen in das Fenster von Spieler 1
  • Das Spiel ist beendet

 

Funktionsumfang

Aus dem Spielablauf ergibt sich folgender Funktionsumfang:

Interface

  • Signalisierung von Spielereignissen
  • Ausgabe von Text
  • Ausgabe von Audiofiles
  • Anzeige des aktiven und passiven Spielers
  • Rollanimation der versenkten Kugeln
  • Setzen der Spielerkugeln auf
  • Kugeltyp "voll"
  • Kugeltyp "halb"
  • Sieg
  • Niederlage

Spielverwaltung

  • Zuweisung der Kugeltypen nach 1. versenkter Kugel
  • Automatischer Spielerwechsel bei Fouls
  • weisse Kugel versenkt
  • gegnerische Kugel versenkt
  • wenn keine Kugel versenkt wird
  • Ende des Spiels
  • erfolgreicher Spielabschluss
  • vorzeitiges Spielende durch Foul
  • Kugel 8 zu früh versenkt

 

Vorbereitung

Da nun bekannt war, welche Funktionen das Interface beinhalten würde, konnte mit der Schaffung der Umgebung für die Programmierung begonnen werden.

Im ersten Schritt wurde ein leeres Flash-Dokument aufgerufen und an die Bedürfnisse der Spieloberfläche angepasst. So wurde die Framerate auf 30 fps und die Dokumentgröße auf die Abmessungen von 1024 x 768 Pixel gesetzt.

Flash-Bibliothek:
Der nächste Arbeitsschritt bestand darin, alle benötigten Materialien in die Dokument-eigene Bibliothek von Flash zu importieren. Neben Bildern des Interfaces und der Billard- und Spielerkugeln waren auch die später benötigten Sounddateien einzubinden. Um die grafischen Objekte innerhalb der Funktionen nutzen zu können, wurden alle Elemente aus der Bibliothek in Movieclips umgewandelt.
Movieclips besitzen den Vorteil, dass sie über Instanznamen von Funktionen aufgerufen und bearbeitet werden können. Zudem lassen sie sich selbst programmieren und mit Methoden versehen.
Bei der Konvertierung der Bilder musste darauf geachtet werden, dass der Ankerpunkt der entstehenden Movieclips an der linken oberen Ecke positioniert wird. Nur so war sicherzustellen, dass alle Elemente den gleichen Ursprungspunkt ( 0 | 0 ) besitzen und somit auf positionsabhängige Animationseffekte gleichartig reagieren. Ausnahme hierbei bildeten lediglich die Billardkugeln, da für die spätere Rollanimation der Ankerpunkt mittig angebracht werden musste.

Zeitleiste:
Nachdem die Konvertierung der Elemente in Movieclips abgeschlossen war, konnte die FlashZeitleiste der Hauptebene eingerichtet werden. Neben der Programmierung, welche auf eine ausgelagerte Scriptebene gelegt wurde, waren Ebenen für Testzwecke, die Ausgabe des Textes und für die Anordnung der Grafiken anzulegen.

Bühne:
Entsprechend der Zeitleiste wurden nun die einzelnen Objekttypen auf der Bühne verteilt. Parallel dazu erhielten die Movieclip-Objekte Instanz-Namen, über die sie über die Funktionen aufgerufen und bearbeitet werden konnten.

Nach Beendigung dieses Arbeitsschrittes enthielt die Bühne folgende Elemente

  • beide Balken des Spielerinterfaces
  • Spielernamen: aktiv und inaktiv
  • Spielerkugeln: Kugeltyp (nicht zugewiesen, "halb", "voll") und Spielende (Sieger, Verlierer)
  • Fenster-Maske: bestehend aus zwei Teilen
  • Testbutton
  • Textfeld: zur Ausgabe von Spielnachrichten

Im Gegensatz zu den Elementen auf der Hauptbühne werden die Billardkugeln für die Interface- fenster zur Laufzeit des Programms mittels einer Funktion dynamisch eingebunden. Damit dies möglich ist, müssen den Kugel-Movieclips bereits in der Bibliothek Instanz-namen gegeben werden. Weiterhin ist es notwendig, die Exportfunktion für Actionscript zu aktivieren.

Programmierung

Kugelarrays steuern sowohl die grafische Rollanimation, als auch die Spielverwaltung

var ifaceKugelArray:Array = new Array();
var kugelSammlung:Array = new Array();

SpielerArrays beinhalten alle Informationen des Spielverlaufs

  • Spielernummer
  • dran/nicht dran
  • aktiv/verloren/gewonnen
  • visible: volle/halbe
  • mit dem Spielverlauf: versenkte Kugeln
var spielerEins:Array = new Array("Spieler 1", 1, "aktiv", false, false);
var spielerZwei:Array = new Array("Spieler 2", 0, "aktiv", false, false);

Setzen der Spielerkugeln auf Anfangszustand

sp01v._visible = spielerEins[3];
sp01h._visible = spielerEins[4];
sp01win._visible = false;
sp01lose._visible = false;
sp02v._visible = spielerZwei[3];
sp02h._visible = spielerZwei[4];
sp02win._visible = false;
sp02lose._visible = false;
sp1a._visible = true;
sp1ia._visible = false;
sp2a._visible = false;
sp2ia._visible = true;

Setzen der Variablen zur Steuerung der Spielerverwaltung und der Rollanimation

spielerWechsler = 2;
ekVersenkt = true;
zielpunkt = 500;
rollgeschwindigkeit = 4;
rotationswinkel = 15;
zug = 1;

Initialisierung der Soundobjekte

var sound0:Sound = new Sound();
sound0.attachSound("s0");
var sound1:Sound = new Sound();
sound1.attachSound("s1");
var sound2:Sound = new Sound();
sound2.attachSound("s2");
var sound3:Sound = new Sound();
sound3.attachSound("s3");

Initialisierung des Textfeldes

textZaehler = 0;
textMovie.html = true;
textMovie.inhalt.htmlText = "";

Erstellen und Positionieren des Movieclips zur dynamischen Positionierung der Interfacekugeln

createEmptyMovieClip("KClip", this.getNextHighestDepth());
KClip._x = 400;
KClip._y = 37;
KClip.setMask(IfaceMaske);

Füllen des Interface-Kugel-Movieclips

for (i=1; i<=15; i++) {
  KClip.attachMovie("bk0"+i, "ifaceKugel"+i, i);
  kugelSammlung.push(this.KClip["ifaceKugel"+i]);

Testfunktion:
Über einen Knopfdruck auf das Buttonobjekt tb1 wird die Funktion SpielZugAbgeschlossen() aufgerufen. Diese lädt daraufhin Kugelwerte in das ifaceKugelArray und ruft ihrerseits die Funktion zur Zugauswertung auf. An die Stelle der Testfunktion tritt die Übergabe eines Arrays an die Funktion ZugAuswertung(), wenn die beiden Programmteile von Interface und Spielphysik zusammengeführt werden. Dieses Array dient dann als einzige Schnittstelle zwischen beiden Systemen, was eine weitgehend unabhängige Funktionalität der beiden Teile gewährleistet.

tb1.onRelease = function() {
 SpielZugAbgeschlossen();
}
 
function SpielZugAbgeschlossen() {
  if (zug == 4) {
    ifaceKugelArray.push(15, 8);
  }
  if (zug == 3) {
    ifaceKugelArray.push(6, 12, 2);
    zug = 4;
  }
  if (zug == 2) {
    ifaceKugelArray.push(3, 5);
    zug = 3;
  }
  if (zug == 1) {
    ifaceKugelArray.push();
    zug = 2;
  }
  if (spielerEins[2] == "aktiv" && spielerZwei[2] == "aktiv") {
    ZugAuswertung();
  }
}

Sobald eine Kugel versenkt wird, tritt die Funktion ZugAuswertung() in Kraft. Diese kontrolliert zunächst das empfangene Array auf dessen Inhalt und führt eine Fallunterscheidung durch. Befinden sich im Array Kugeln, so wird kontrolliert, ob im Vorfeld bereits eine Kugel versenkt wurde. Ist dies nicht der Fall und ist die überprüfte Kugel nicht die "Weiße", so ruft die Funktion die Anweisung zur Verwaltung der Spielerkugeln auf. Unabhängig von der ersten versenkten Kugel wird KugelVersenkt() gestartet. Enthält das Array keine Kugeln, so wird ein Spielerwechsel veranlasst und eine Mitteilung über diesen Vorgang durch das Abspielen einer Audiodatei ausgegeben.

function ZugAuswertung() {
  if (ifaceKugelArray.length>0) {
    if (ekVersenkt == true && ifaceKugelArray[0] != "weiss") {
      if (spielerEins[1] == 1) {
        ErsteKugelVersenkt(spielerEins, spielerZwei);
      } else if (spielerZwei[1] == 1) {
        ErsteKugelVersenkt(spielerZwei, spielerEins);
      }
      IfaceSpKugelAnzeige();
      IfaceKugelVerschiebY();
      ekVersenkt = false;
    }
    if (spielerEins[1] == 1) {
      KugelVersenkt(spielerEins, spielerZwei);
    } else if (spielerZwei[1] == 1) {
      KugelVersenkt(spielerZwei, spielerEins);
    }
  } else if (ifaceKugelArray.length == 0) {
    SpielerWechsel();
    sound1.start();
  }
}

ErsteKugelVersenkt() tritt bei einem gleichnamigen Ereignis in Kraft. Die Aufgabe dieser Funktion ist es, den Spielern die entsprechenden Kugeltypen zuzuweisen.

function ErsteKugelVersenkt(treffSpieler, verpasstSpieler) {
  if (ifaceKugelArray[0]<=8) {
    treffSpieler[3] = true;
    verpasstSpieler[4] = true;
  }
  if (ifaceKugelArray[0]>8) {
    treffSpieler[4] = true;
    verpasstSpieler[3] = true;
  }
}

KugelVersenkt() bildet den zentralen Kern des Interfaces.
Von der Funktion ZugAuswertung() aufgerufen mißt sie zunächst die Länge des ifaceKugelArrays, welches alle versenkten Kugeln des letzten Spielzuges beinhaltet. Daraufhin arbeitet sie das Array Schritt für Schritt durch.
Hat der Spieler die richtige Kugel versenkt, so wird sie ihm durch das Anhängen an sein SpielerArray gutgeschrieben. Zudem startet die Rollfunktion für die versenkte Interfacekugel.
Wird eine gegnerische Kugel versenkt, so erhält der Gegenspieler diese Kugel für sein Arrray und sein Interfacefenster. Der aktive Zustand von Spieler 1 ändert sich von 1 auf 0. Abschließend wird der aktuelle Kugelwert aus dem Array entfernt.
Sollte die 8 eingelocht werden, so checkt die Funktion zunächst, ob Spieler 2 diese versenkt hat. Ist dies der Fall, so ändert sich die y-Koordinate der Kugel, sodass sie in das Interfacefenster von Spieler 2 rollen kann. Im nächsten Schritt startet die Rollanimation für die schwarze 8. Parallel dazu wird der Kugelwert an der Position 0 des ifaceKugelArrays gelöscht.
Hat der Spieler vor der 8 alle anderen Kugeln des zugewiesenen Typs eingelocht, so ändert sich der Zustand auf gewonnen. Ist Spieler 1 der Sieger so wird das Textfeld mit einem Lob für ihn bestückt und die Funktion ifaceSpielerEinsSieg() aufgerufen.
Analog dazu wird verfahren, wenn Spieler 2 das Spiel gewonnen hat.
Ein Spielteilnehmer, der die schwarze 8 zu früh versenkt, verliert das Spiel. Die Ausgabe eines Sounds weist ihn auf diesen Fehler und die damit verbundene Niederlage hin. Gleichzeitig wird dem Textfeld die Nachricht mit einer Erklärung des Fehlers zugewiesen. Je nach Spieler ändert sich der aktive Zustand auf passiv.
Durch das Einlochen der weißen Kugel wird diese aus dem ifaceKugelArray() gelöscht und der aktive Zustand des ausführenden Spielers aufgehoben.
Ist am Ende des Funktionsablaufes von KugelVersenkt() der aktive Zustand des Spielers aufgehoben, so hat er in jedem Fall das Foul begangen, die falsche Kugel versenkt zu haben. Dies wird mit der Ausgabe von Sound und Text signalisiert. Zudem tritt die Funktion Spielerwechsel in Kraft.

function KugelVersenkt(treffSpieler, verpasstSpieler) {
  ifaceKugelArrayLang = ifaceKugelArray.length;
  for (i=0; i<ifaceKugelArrayLang; i++) {
    if (ifaceKugelArray[0] != 8 && ifaceKugelArray[0] != "weiss") {
      if (treffSpieler[3] == true) {
        if (ifaceKugelArray[0]<8) {
          IfaceKugelAbstand(kugelSammlung[ifaceKugelArray[0]-1], treffSpieler.length-5);
          treffSpieler.push(kugelSammlung[ifaceKugelArray[0]-1]);
        } else {
          IfaceKugelAbstand(kugelSammlung[iface
          KugelArray[0]-1], verpasstSpieler.length-5);
          verpasstSpieler.push(kugelSammlung[ifaceKugelArray[0]-1]);
          treffSpieler[1] = 0;
        }
      }
      if (treffSpieler[4] == true) {
        if (ifaceKugelArray[0]>8) {
          IfaceKugelAbstand(kugelSammlung[ifaceKugelArray[0]-1], treffSpieler.length-5);
          treffSpieler.push(kugelSammlung[ifaceKugelArray[0]-1]);
        } else {
          IfaceKugelAbstand(kugelSammlung[iface
          KugelArray[0]-1], verpasstSpieler.length-5);
          verpasstSpieler.push(kugelSammlung[ifaceKugelArray[0]-1]);
          treffSpieler[1] = 0;
        }
      }
      ifaceKugelArray.shift();
    } else if (ifaceKugelArray[0] == 8) {
      if (treffSpieler[0] == "Spieler 2") {
        kugelSammlung[ifaceKugelArray[0]-1]._y = 704;
      }
      IfaceKugelAbstand(kugelSammlung[ifaceKugelArray[0]-1], treffSpieler.length-5);
      treffSpieler.push(kugelSammlung[ifaceKugelArray[0]-1]);
      ifaceKugelArray.shift();
      if (treffSpieler.length == 13) {
        treffSpieler[2] = "gewonnen";
        textMovie.inhalt.htmlText = "Glueckwunsch
"+treffSpieler[0]+"!
Du hast gewonnen!"; if (treffSpieler[0] == "Spieler 1") { IfaceSpielerEinsSieg(); } else { IfaceSpielerZweiSieg(); } } else { treffSpieler[2] = "verloren"; sound0.start(); textMovie.inhalt.htmlText = treffSpieler[0]+"?

Die 8 muss erst am Schluss ins Loch"; treffSpieler[1] = 0; if (treffSpieler[0] == "Spieler 1") { IfaceSpielerZweiSieg(); } else { IfaceSpielerEinsSieg(); } } } else if (ifaceKugelArray[0] == "weiss") { treffSpieler[1] = 0; ifaceKugelArray.shift(); } } if (treffSpieler[1] == 0) { if (treffSpieler[2] == "aktiv") { SpielerWechsel(); sound3.start(); textMovie.inhalt.htmlText = treffSpieler[0]+"

Das war die falsche Kugel"; } } else if (treffSpieler[1] == 1) { sound2.start(); } }

SpielerWechsel() ändert den Zustand der Spieler von aktiv auf passiv und umgekehrt.

function SpielerWechsel() {
  if (spielerWechsler%2 == 1) {
    spielerWechsler = 1;
    spielerEins[1] = 1;
    spielerZwei[1] = 0;
  } else {
    spielerEins[1] = 0;
    spielerZwei[1] = 1;
  }
  spielerWechsler++;
  IfaceWerIstDran();
}

Da alle Billardkugeln nach der dynamischen Einbindung auf einem Koordinatenpunkt liegen, müssen die durch den Typ zugewiesenen Kugeln für Spieler 2 auf die Position dessen Interfaces verschoben werden. Diese Aufgabe übernimmt die Funktion ifaceKugelVerschiebY().

function IfaceKugelVerschiebY() {
  if (spielerZwei[3] == true) {
    for (i=0; i<7; i++) {
      kugelSammlung[i]._y = 704;
    }
  } else if (spielerZwei[4] == true) {
    for (i=8; i<16; i++) {
      kugelSammlung[i]._y = 704;
    }
  }
}

IfaceSpKugelAnzeige() setzt die Bilder der Spielerkugeln je nach deren Typ auf sichtbar oder unsichtbar.

function IfaceSpKugelAnzeige() {
  sp01w._visible = false;
  sp01v._visible = spielerEins[3];
  sp01h._visible = spielerEins[4];
  sp02w._visible = false;
  sp02v._visible = spielerZwei[3];
  sp02h._visible = spielerZwei[4];
}

Abhängig vom Ende des Spiels setzt eine der beiden folgenden Funktionen den Zustand der Spielerkugeln auf gewonnen oder verloren.

function IfaceSpielerEinsSieg() {
  sp01v._visible = false;
  sp01h._visible = false;
  sp02v._visible = false;
  sp02h._visible = false;
  sp01win._visible = true;
  sp02lose._visible = true;
}
 
function IfaceSpielerZweiSieg() {
  sp01v._visible = false;
  sp01h._visible = false;
  sp02v._visible = false;
  sp02h._visible = false;
  sp01lose._visible = true;
  sp02win._visible = true;
}

IfaceWerIstDran() ändert die Sichtbarkeit der Bilder der Spielernamen und zeigt somit den aktiven und den passiven Zustand beider Anwender an.

function IfaceWerIstDran() {
  if (spielerEins[1] == 1) {
    sp1a._visible = true;
    sp1ia._visible = false;
    sp2a._visible = false;
    sp2ia._visible = true;
  } else {
    sp1a._visible = false;
    sp1ia._visible = true;
    sp2a._visible = true;
    sp2ia._visible = false;
  }
}

Die drei nachfolgenden Funktionen behandeln die Rollanimation der versenkten Spielerkugeln.

function IfaceKugelRollen(rollKugel) {
  if (rollKugel._x<=rollKugel.endpunkt) {
    rollKugel._x = rollKugel._x+rollgeschwindigkeit;
    rollKugel._rotation = rollKugel._rotation+rotationswinkel;
  }
}
function IfaceKugelAbstand(abstandKugel, distanz) {
  abstandKugel._x = abstandKugel._x-distanz*45;
  abstandKugel.endpunkt = zielpunkt-(distanz*45);
}
 
onEnterFrame = function () {
  for (a=0; a<=spielerEins.length-5; a++) {
    IfaceKugelRollen(spielerEins[5+a]);
  }
  for (a=0; a<=spielerZwei.length-5; a++) {
    IfaceKugelRollen(spielerZwei[5+a]);
  }
  IfaceTextAusbl();
}

Mit Hilfe der Funktion IfaceTextAusbl() ist die dynamische Ausgabe von Texten möglich. Mit steigender Anzahl der Zeichen verlängert sich auch die Anzeigedauer in Abhängigkeit der Framerate.

function IfaceTextAusbl() {
  if (textMovie.inhalt.length>0) {
    textZaehler++;
    //textMovie.inhalt._alpha = 100-textZaehler/2;
  }
  if (textZaehler%(textMovie.inhalt.length*3) == 0) {
    textMovie.inhalt.replaceText(0, (textMovie.inhalt.length), "");
    textZaehler = 0;
  }
}