In diesem Beispiel wollen wir das Wissen aus den vorangegangenen Beispielen anwenden und ein einfaches Spiel nach dem Vorbild von Ataris Pong entwickeln. Das Spiel wird dann in den weiteren Beispielen noch verfeinert und auch für Smartphones und Tablets angepasst.
Version 1: Paddle und Ball
Wir beginnen mit der Definition der einzelnen Graphenknoten in den XML-Dateien. Am Bildschirm sollen links und rechts je ein Rechteck für das Paddle platziert sein, in der Mitte ein Quadrat für den Ball sowie eine vertikale, strichlierte Mittellinie für das Netz.
Übungen
- Versuche zuerst eine eigene Version zu entwickeln und vergleiche dein Ergebnis mit der hier gezeigten Lösung.
Für die Anzeige des Balls und der Paddles verwenden wir PlaneGeometry
-Knoten und skalieren diese mit dem Attribut scaleFactorX
bzw. scaleFactorY
auf die gewünschte Größe.
<?xml version="1.0" ?> <Graph> <Instance graphResourceId="package_main:graph_mat"/> <View id="view"/> <Camera id="camera" viewId="view" fieldOfViewX="400" nearPlane="400" farPlane="2500" clearColorBuffer="1" /> <CameraTransform cameraId="camera" posX="0" posY="0" posZ="800" /> <CameraState cameraId="camera" /> <MaterialState materialId="material/mat_white" /> <PlaneGeometry id="paddleLeft" scaleFactorX="20" scaleFactorY="100" posX="-350" posY="0" posZ="0" /> <PlaneGeometry id="paddleRight" scaleFactorX="20" scaleFactorY="100" posX="350" posY="0" posZ="0" /> <PlaneGeometry id="ball" scaleFactor="10" posX="0" posY="0" posZ="0" />
Die strichlierte Mittellinie wird mit 14 einzelnen Teilstrichen konstruiert. Normalerweise müssten wir also 14 PlaneGeometry
-Elemente in der XML-Datei definieren. Alternativ kann das Attribut replications
eines Graph::Instance
-Knotens verwendet werden, um ein Objekt mehrmals zu instanzieren. Die Angabe von replications="14"
bewirkt, dass die angegebene Ressource 14mal instanziert und jede einzelne dieser Instanzen dem Graphen hinzugefügt wird:
<Instance graphResourceId="package_main:graph_line" replications="14" myWidth="5"/> </Graph>
Um die id
-Namen der Instanzen noch unterscheidbar zu machen, wird der Ressourcedatei beim Instanzieren das Attribut replication
mit der Instanznummer als Wert "übergeben". In der Ressourcedatei wird dann jedes Vorkommen von {replication}
mit der Instanznummer ersetzt, im Bereich von 0 bis (replications-1).
<?xml version="1.0" ?> <Graph myWidth="5"> <PlaneGeometry id="line_{replication}" scaleFactorX="{myWidth}" scaleFactorY="20" posX="0" posY="400" posZ="0" /> </Graph>
Die erzeugten Instanzen haben also die IDs "line_0"
, "line_1"
, "line_2"
etc., bis "line_13"
.
Nach demselben Schema können auch selbst definierte Attribute an die Ressourcendatei übergeben werden. Beispielsweise könnte die Breite der Linienelemente als selbst definiertes Attribut "myWidth"
mit einem konkreten Wert, z.B. e.g. myWidth="2"
verwendet werden. Innerhalb der Ressourcendatei wird dann jedes Vorkommen des Strings {myWidth}
durch den übergebenen Wert ersetzt:
<Instance graphResourceId="package_main:graph_line" replications="14" myWidth="2"/>
In der Ressourcendatei können Standardwerte für selbst definierte Attribute im Wurzelelement Graph
angegeben werden. Dadurch wird verhindert, dass Werte unbekannt bleiben, wenn die Angabe beim Erzeugen der Instanz vergessen wurde. Der Wert in der Instanz überschreibt den Standardwert:
<?xml version="1.0" ?> <Graph myWidth="5"> <PlaneGeometry id="line_{replication}" scaleFactorX="{myWidth}" scaleFactorY="20" posX="0" posY="400" posZ="0" /> </Graph>
- Zu beachten
- Info: Die Übergabe von Attributwerten mit in geschwungenen Klammern gesetzten Attributnamen ist eine Besonderheit der Murl Engine und ist nicht XML-konform.
In der Datei pong_logic.h
definieren wir noch drei TransformNode
-Objekte, um die Paddles und den Ball manipulieren zu können:
Logic::TransformNode mBallTransform; Logic::TransformNode mPaddleLeftTransform; Logic::TransformNode mPaddleRightTransform;
Die Verbindung mit den Knotenelementen im Graphen erfolgt wieder in der Methode OnInit()
:
Bool App::PongLogic::OnInit(const Logic::IState* state) { state->GetLoader()->UnloadPackage("startup"); Graph::IRoot* root = state->GetGraphRoot(); //root->PrintTree();
Der TransformNode
mBallTransform
wird zunächst verwendet, um die Y-Position der 14 Linienelemente festzulegen. Für jedes Linienelement wird zuerst eine Referenz erzeugt, die Position gesetzt und danach die Referenz wieder freigegeben.
// lay out middle line SInt32 i; const SInt32 maxInstances = 14; for (i=0; i < maxInstances; i++) { mBallTransform.GetReference(root, "line_"+Util::UInt32ToString(i)); mBallTransform->SetPositionY(-20.0 + (maxInstances/2 - i)*40); mBallTransform.RemoveReference(); }
Danach wird die Verbindung der Paddle-Knoten und des Ball-Knotens mit den entsprechenden Member-Objekten hergestellt, mit Hilfe der Mehoden TransformNode::GetReference()
und BaseProcessor::AddGraphNode()
:
// get references for ball and paddles AddGraphNode(mBallTransform.GetReference(root, "ball")); AddGraphNode(mPaddleLeftTransform.GetReference(root, "paddleLeft")); AddGraphNode(mPaddleRightTransform.GetReference(root, "paddleRight")); if (!AreGraphNodesValid()) { return false; } return true; }
Als Ergebnis erhalten wird das Pong-Spielfeld mit zwei Paddles, einem Ball und einer strichlierten Mittellinie:
Version 2: Steuerung
Im nächsten Schritt wird der Code erweitert, um den Ball und die beiden Paddles steuern zu können. Die Steuerung soll vorerst nur mit Maus und Tastatur erfolgen.
Paddle-Position
Wir deklarieren eine neue Methode, in der wir die Position der Paddles updaten sowie zwei Membervariablen, um die aktuelle Position der Paddle zu speichern:
void UpdatePaddlePosition(Logic::IDeviceHandler* deviceHandler, Double tickDuration);
Real mPaddleLeftPosY; Real mPaddleRightPosY;
Die beiden Membervariablen werden im Konstruktor auf 0 initialisiert:
App::PongLogic::PongLogic(Logic::IFactory* factory) : BaseProcessor(factory) , mGameIsPaused(true) , mPaddleLeftPosY(0) , mPaddleRightPosY(0) , mBallPosX(0) , mBallPosY(0) , mBallDirectionX(1) , mBallDirectionY(1) , mBallSpeed(1) { }
Die neue Methode wird dann in der OnProcessTick()
-Methode aufgerufen. Als Parameter wird der deviceHandler
und die tickDuration
übergeben.
Mit dem deviceHandler
können Eingaben über z.B. Touch-Screen, Maus oder Tastatur abgefragt werden. Der Wert in der Variable tickDuration
entspricht genau der berechneten Tick-Zeit (tick time) in Sekunden bis zum nächsten Aufruf der Methode OnProcessTick()
. Damit kann z.B. bei gegebener Geschwindigkeit genau der zurückgelegte Weg des Balls und damit die neue Position berechnet werden.
void App::PongLogic::OnProcessTick(const Logic::IState* state) { Logic::IDeviceHandler* deviceHandler = state->GetDeviceHandler(); Double tickDuration = state->GetCurrentTickDuration(); UpdatePaddlePosition(deviceHandler, tickDuration); }
In der Methode UpdatePaddlePosition()
wird die Position der Paddles bestimmt. Für das rechte Paddle verwenden wir die Methode IDeviceHandler::GetMousePosition()
, welche die Position des Mauszeigers liefert. Die gelieferten X- und Y-Werte liegen immer genau zwischen -1 und +1 und sind daher unabhängig von Fenstergröße, Seitenverhältnis oder Kamerawinkel.
Die dargestellte virtuelle Welt hat auf der Z-Ebene 0 eine Größe von 800 x 600 Einheiten. Wir müssen daher den Y-Wert noch mit 300 multiplizieren, um den Y-Wert der Mauskoordinate in einen Y-Wert in der virtuellen Welt umzurechnen.
void App::PongLogic::UpdatePaddlePosition(Logic::IDeviceHandler* deviceHandler, Double tickDuration) { // Right Paddle Mouse if (deviceHandler->WasMouseMoved()) { Real posX; deviceHandler->GetMousePosition(posX, mPaddleRightPosY); mPaddleRightTransform->SetPositionY(mPaddleRightPosY*300); }
Für das linke Paddle verwenden wir die Methode IDeviceHandler::IsRawKeyPressed()
. Die Schrittweite wird mit der Variable tickDuration
multipliziert, um eine konstante Geschwindigkeit unabhängig von der Framerate für das Paddle zu erhalten. Anderenfalls würde sich das Paddle abhängig von der Logik-Schrittweite unterschiedlich schnell bewegen.
// Left Paddle Keyboard if (deviceHandler->IsRawKeyPressed(RAWKEY_UP_ARROW )) { mPaddleLeftPosY += Real(3)*tickDuration; if (mPaddleLeftPosY > Real(1)) mPaddleLeftPosY = Real(1); mPaddleLeftTransform->SetPositionY(mPaddleLeftPosY*300); } if (deviceHandler->IsRawKeyPressed(RAWKEY_DOWN_ARROW )) { mPaddleLeftPosY -= Real(3)*tickDuration; if (mPaddleLeftPosY < Real(-1)) mPaddleLeftPosY = Real(-1); mPaddleLeftTransform->SetPositionY(mPaddleLeftPosY*300); } }
Game States
Nach demselben Schema deklarieren wir eine neue Membervariable mGameIsPaused
und eine protected
-Methode UpdateGameStates()
, um zwischen einzelnen Game-States umzuschalten.
void App::PongLogic::OnProcessTick(const Logic::IState* state) { Logic::IDeviceHandler* deviceHandler = state->GetDeviceHandler(); Double tickDuration = state->GetCurrentTickDuration(); UpdateGameStates(deviceHandler); UpdatePaddlePosition(deviceHandler, tickDuration); }
Wir verwenden die linke Maustaste oder die Leertaste, um das Spiel zu pausieren und die ESC-Taste bzw. die Zurück-Taste bei Android, um die Applikation zu beenden.
void App::PongLogic::UpdateGameStates(Logic::IDeviceHandler* deviceHandler) { // Game Pause if ((deviceHandler->WasRawKeyPressed(RAWKEY_SPACE)) || (deviceHandler->WasMouseButtonPressed(IEnums::MOUSE_BUTTON_LEFT))) { mGameIsPaused = !mGameIsPaused; } // Exit if (deviceHandler->WasRawKeyPressed(RAWKEY_ESCAPE) || deviceHandler->WasRawButtonPressed(RAWBUTTON_BACK)) { deviceHandler->TerminateApp(); } }
Ball-Position
Für die Berechnung des Balls deklarieren wir mehrere Methoden und Membervariablen.
void InitBallPosition(); void MissedBall(); void SetAndNormalizeBallDirection(Real x, Real y); void UpdateGameStates(Logic::IDeviceHandler* deviceHandler); void UpdatePaddlePosition(Logic::IDeviceHandler* deviceHandler, Double tickDuration); void UpdateBallPosition(Logic::IDeviceHandler* deviceHandler, Double tickDuration); Logic::TransformNode mBallTransform; Logic::TransformNode mPaddleLeftTransform; Logic::TransformNode mPaddleRightTransform; Util::TT800 mRandomNumberGenerator; Bool mGameIsPaused; Real mPaddleLeftPosY; Real mPaddleRightPosY; Real mBallPosX, mBallPosY; Real mBallDirectionX, mBallDirectionY; Real mBallSpeed;
Mit der Methode SetAndNormalizeBallDirection()
kann ein neuer Wert für die Richtung des Balls festgelegt werden, wobei die Länge des Richtungsvektors auf 1 normalisiert wird. Damit kann die Geschwindigkeit des Balls unabhängig vom Richtungsvektor festgelegt werden.
void App::PongLogic::SetAndNormalizeBallDirection(Real x, Real y) { // Normalize and update direction vector Real divisor = Math::Sqrt(x*x + y*y); x /= divisor; y /= divisor; mBallDirectionX = x; mBallDirectionY = y; }
Die Methode InitBallPosition()
wird für die Initialisierung der Werte verwendet. Um die Richtung des Balls zufällig zu gestalten, wird eine Instanz (mRandomNumberGenerator
) der Zufallszahlengeneratorklasse Util::TT800
verwendet. Die Methode Murl::Util::Rng::RandReal(Real,Real),RandReal(Real(-1.0), Real(+1.0))
liefert einen zufälligen Wert im Bereich von -1 bis +1.
void App::PongLogic::InitBallPosition() { Real randomNumber = mRandomNumberGenerator.RandReal(Real(-1.0), Real(+1.0)); mBallPosX = 0; mBallPosY = 0; SetAndNormalizeBallDirection(Real(1), randomNumber); mBallSpeed = 400; mBallTransform->SetPositionX(mBallPosX); mBallTransform->SetPositionY(mBallPosY); mGameIsPaused = true; }
In der Methode UpdateBallPosition()
wird die aktuelle Position des Balls berechnet und, wenn der Ball verfehlt wird, die Methode MissedBall()
aufgerufen. Je nach Aufprallposition am Paddle, ändert sich die Richtung des Balls beim Rückschlag. Außerdem wird die Geschwindigkeit bei jedem erfolgreichen Rückschlag etwas erhöht.
void App::PongLogic::UpdateBallPosition(Logic::IDeviceHandler* deviceHandler, Double tickDuration) { if (mGameIsPaused) return; mBallPosX += mBallDirectionX*mBallSpeed*tickDuration; mBallPosY += mBallDirectionY*mBallSpeed*tickDuration; // Intentional ignore ball width for collisionPosition // collisionPositionX = paddlePosition - paddleWidth/2 = 350 - 20/2 = 340 if (mBallPosX >= 340) { Real distance = mPaddleRightPosY*300 - mBallPosY; if (Math::Abs(distance) <= 55) { SetAndNormalizeBallDirection(Real(-1),Real(-2)*distance/55); mBallSpeed += Real(50); } else { MissedBall(); } } // Intentional ignore ball width for collisionPosition if (mBallPosX <= -340) { Real distance = mPaddleLeftPosY*300 - mBallPosY; if (Math::Abs(distance) <= 55) { SetAndNormalizeBallDirection(Real(1), Real(-2)*distance/55); mBallSpeed += Real(50); } else { MissedBall(); } } if (mBallPosY > 295) { mBallPosY = 590 - mBallPosY; mBallDirectionY *= -1; } else if (mBallPosY < -295) { mBallPosY = -590 - mBallPosY; mBallDirectionY *= -1; } mBallTransform->SetPositionX(mBallPosX); mBallTransform->SetPositionY(mBallPosY); }
In der Methode MissedBall()
werden die Variablen wieder initialisiert und die Richtung des Balls korrigiert, sodass der Ball bei einem Neustart immer in Richtung Punktgewinner startet.
void App::PongLogic::MissedBall() { if (mBallPosX > 0) { InitBallPosition(); mBallDirectionX *= -1; } else { InitBallPosition(); } }
Das Ergebnis ist eine erste spielbare Version des Spiels, allerdings noch ohne Punktezähler.
Version 3: Punktezähler
Als letztes erstellen wir noch eine einstellige Siebensegmentanzeige, um Punkte bzw. die Anzahl der Fehler mitzuzählen.
<?xml version="1.0" ?> <Graph posX="0" posY="0" idExt=""> <Namespace id="segment{idExt}"> <Transform id="transform" posX="{posX}" posY="{posY}" > <PlaneGeometry id="a" scaleFactorX="56" scaleFactorY="6" posX="0" posY="50" posZ="0" /> <PlaneGeometry id="b" scaleFactorX="6" scaleFactorY="53" posX="25" posY="25" posZ="0" /> <PlaneGeometry id="c" scaleFactorX="6" scaleFactorY="53" posX="25" posY="-25" posZ="0" /> <PlaneGeometry id="d" scaleFactorX="56" scaleFactorY="6" posX="0" posY="-50" posZ="0" /> <PlaneGeometry id="e" scaleFactorX="6" scaleFactorY="53" posX="-25" posY="-25" posZ="0" /> <PlaneGeometry id="f" scaleFactorX="6" scaleFactorY="53" posX="-25" posY="25" posZ="0" /> <PlaneGeometry id="g" scaleFactorX="56" scaleFactorY="6" posX="0" posY="0" posZ="0" /> </Transform> </Namespace> </Graph>
Wir instanzieren und positionieren je eine Anzeige für den linken und den rechten Spieler.
<Instance graphResourceId="package_main:graph_line" replications="14" myWidth="5"/> <Instance graphResourceId="package_main:graph_segment" posX="-180" posY="200" idExt="Left"/> <Instance graphResourceId="package_main:graph_segment" posX="180" posY="200" idExt="Right"/>
In der Datei pong_logic.h
deklarieren wir für jeden Spieler eine Variable für den Punktestand, einen TransformNode
um die Referenz auf den Anzeigeknoten zu speichern, sowie die Methoden ResetScore()
und UpdateScore()
.
void ResetScore(); void UpdateScore(Logic::TransformNode& transform, UInt32 score); UInt32 scoreLeft, scoreRight; Logic::TransformNode mSegmentLeftTransform; Logic::TransformNode mSegmentRightTransform;
In der Methode OnInit()
wird der Transform-Knoten mit der ID "transform"
als Referenz gespeichert. Der Namespace dafür lautet "segmentLeft"
bzw. "segmentRight"
.
AddGraphNode(mSegmentLeftTransform.GetReference(root, "segmentLeft/transform")); AddGraphNode(mSegmentRightTransform.GetReference(root, "segmentRight/transform"));
In der Methode ResetScore()
werden die beiden Score-Werte auf 0 gesetzt und die Methode UpdateScore()
aufgerufen.
void App::PongLogic::ResetScore() { scoreLeft = 0; UpdateScore(mSegmentLeftTransform, scoreLeft); scoreRight = 0; UpdateScore(mSegmentRightTransform, scoreRight); }
In der Methode UpdateScore()
wird die Sichtbarkeit der jeweiligen Segmente abhängig vom Score-Wert ein bzw. ausgeschaltet.
Zuerst wird mit ITransform::GetNodeInterface()
ein Zeiger auf den Graphenknoten vom Typ Graph::INode
geholt und mit node->GetChild(i)
der Reihe nach auf die einzelnen Kinderelemente zugegriffen. Die Kodierung für die einzelnen Segmente wird mit dem Array displayValues
für die Zahlen 0 bis 9 fest vorgegeben. Dabei wird eine bitweise Kodierung verwendet. Das niederwertigste Bit steuert das Segment a, das nächste Bit steuert das Segment b usw.
Mit dem Modulo-Operator score % 10
wird sichergestellt, dass der Arrayzugriff nur innerhalb des erlaubten Wertebereichs von 0 bis 9 erfolgt. Mit displayValue % 2
wird das niederwertigste Bit ausmaskiert und mit displayValue >> 1
wird der Wert um ein Bit nach rechts verschoben (durch 2 dividiert).
void App::PongLogic::UpdateScore(Logic::TransformNode& transform, UInt32 score) { Graph::INode* node = transform->GetNodeInterface(); const UInt32 displayValues[10] = {63, 6, 91, 79, 102, 109, 125, 7, 127, 111}; int displayValue = displayValues[score % 10]; UInt32 i; for (i=0; i<7; i++) { if ((displayValue % 2) == 1) node->GetChild(i)->SetVisible(true); else node->GetChild(i)->SetVisible(false); displayValue = displayValue >> 1; } }
Als letztes führen wir noch eine Anpassung in der Methode UpdateGameStates()
durch. Wenn der maximale Fehlerscore von 9 erreicht wurde, soll ein neues Spiel gestartet und der Score von beiden Spielern wieder auf 0 gesetzt werden.
void App::PongLogic::UpdateGameStates(Logic::IDeviceHandler* deviceHandler) { // Game Pause if ((deviceHandler->WasRawKeyPressed(RAWKEY_SPACE)) || (deviceHandler->WasMouseButtonPressed(IEnums::MOUSE_BUTTON_LEFT))) { mGameIsPaused = !mGameIsPaused; if (mGameIsPaused == false) if ((scoreLeft == 9) || (scoreRight == 9)) ResetScore(); } // Exit if (deviceHandler->WasRawKeyPressed(RAWKEY_ESCAPE) || deviceHandler->WasRawButtonPressed(RAWBUTTON_BACK)) { deviceHandler->TerminateApp(); } }
Als Ergebnis erhalten wir eine spielbare Version des Spieleklassikers Pong.