Tutorial #03: Pong

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"/>
    <PerspectiveCamera
        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:

tut0103_pong_v1.png
V1 Das Pong-Spielfeld

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.

tut0103_mouse_input_range.png
Wertebereich der Mauskoordinaten

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.

tut0103_pong_v2.png
V2: Erste spielbare Pong-Version

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.

tut0103_segments.png
Die sieben Segmente der Punkteanzeige

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.

tut0103_pong_v3.png
V3: Pong mit Punktezähler


Copyright © 2011-2018 Spraylight GmbH.