Tutorial #03: Card Game

Aufbauend auf den vorherigen Beispielen wird hier demonstriert, wie mit den bisher gezeigten Techniken ein kleines Kartenspiel implementiert werden kann.

Dieses Beispiel geht im Speziellen darauf ein, wie man mehrfache Instanzen von Grafikobjekten elegant verwenden kann (in unserem Beispiel ein ganzer Stapel Spielkarten).

Die Fonts aus dem Bitmap-Font Beispiel wurden in das Verzeichnis "fonts" gelegt und das dazugehörige Skript angepasst.

Für unser Beispiel haben wir vorgefertigte Kartengrafiken von https://code.google.com/p/vector-playing-cards geladen und ausgewählte Dateien in das Verzeichnis PNG-cards-1.3 gelegt.

  • data/orig/fonts
  • data/orig/PNG-cards-1.3/clubs
  • data/orig/PNG-cards-1.3/diamonds
  • data/orig/PNG-cards-1.3/hearts
  • data/orig/PNG-cards-1.3/misc
  • data/orig/PNG-cards-1.3/spades

Da eine Textur mit allen Karten ungünstig groß werden würde, entscheiden wir uns dazu, von jedem Kartentyp eine eigene Textur zu erstellen. Zu diesem Zweck wurden die Karten in gleichnamige Unterverzeichnisse verschoben, um das Erstellen von einzelnen Kartentexturen mit einer Konfigurationsdatei zu ermöglichen.

  • scripts/atlas_config_cards.xml

Diese hat die Besonderheit, dass ein externes Attribut mit dem Namen cardType verwendet wird. Externe Attribute werden beim Aufruf des atlas_generator im Skript gesetzt.

<?xml version="1.0"?>
<!-- Copyright 2013 Spraylight GmbH -->

<AtlasGenerator cardType="">
    
    <Input path="../data/orig/PNG-cards-1.3/{cardType}">
        
        <Matte color="0i, 0i, 0i"/>
        
        <Image scanAll="yes" sizeX="90" sizeY="130"/>
        
    </Input>
    
    <Output path="../data/packages/game.murlres">
        
        <Atlas sizeRaster="2"/>
        
        <Image name="gfx_{cardType}.png" margin="1"/>
        
        <PlaneGraphXML name="planes_{cardType}.xml"/>
        
    </Output>
    
</AtlasGenerator>

Hier sieht man, dass das cardType Attribut an diversen Stellen als Substitut eingesetzt wird. Der Vollständigkeit halber kann auch ein Vorgabewert für externe Attribute im Root-Tag gesetzt werden. <AtlasGenerator cardType=""> bewirkt konkret nichts, wird aber zur Demonstration angeführt.

Da die Karten mit sehr hoher Auflösung vorliegen werden diese auf eine passende Größe skaliert <Image scanAll="yes" sizeX="90" sizeY="130"/>. Hier zeigt sich deutlich, dass ein Skalieren mit einen großen Skalierungsfaktor zu Qualitätsverlust führt. Für unser Beispiel ist dies vertretbar, in der Praxis empfiehlt es sich jedoch, die Vektordaten der Karten herunterzuladen und in einem Grafikprogramm mit der gewünschten Größe als PNG zu speichern.

Das entsprechende Skript ruft den atlas_generator wie folgt auf:

%MURL_ATLAS_GENERATOR% -q --config atlas_config_cards.xml --attribute cardType="clubs"
%MURL_ATLAS_GENERATOR% -q --config atlas_config_cards.xml --attribute cardType="diamonds"
%MURL_ATLAS_GENERATOR% -q --config atlas_config_cards.xml --attribute cardType="hearts"
%MURL_ATLAS_GENERATOR% -q --config atlas_config_cards.xml --attribute cardType="spades"
%MURL_ATLAS_GENERATOR% -q --config atlas_config_cards.xml --attribute cardType="misc"

Somit entstehen aus den Einzelbildern in den fünf Verzeichnissen jeweils eine entsprechende Textur und eine dazu passende Graphen-Datei.

Szenengraph

Für dieses Beispiel wird der Szenengraph in mehrere einzelne Dateien mit dem Präfix "graph_" unterteilt.

<?xml version="1.0" ?>
<!-- Copyright 2013 Spraylight GmbH -->
<Package id="game">
    
    <!-- Animation resources -->
    <Resource id="anim_game_screen"     fileName="anim_game_screen.xml"/>
    
    <!-- Bitmap resources -->
    <Resource id="gfx_clubs"            fileName="gfx_clubs.png"/>
    <Resource id="planes_clubs"         fileName="planes_clubs.xml"/>
    <Resource id="gfx_diamonds"         fileName="gfx_diamonds.png"/>
    <Resource id="planes_diamonds"      fileName="planes_diamonds.xml"/>
    <Resource id="gfx_hearts"           fileName="gfx_hearts.png"/>
    <Resource id="planes_hearts"        fileName="planes_hearts.xml"/>
    <Resource id="gfx_misc"             fileName="gfx_misc.png"/>
    <Resource id="planes_misc"          fileName="planes_misc.xml"/>
    <Resource id="gfx_spades"           fileName="gfx_spades.png"/>
    <Resource id="planes_spades"        fileName="planes_spades.xml"/>
    
    <!-- Font resources -->
    <Resource id="arial_color_24_glyphs" fileName="fonts/arial_color_24_glyphs.murl"/>
    <Resource id="arial_color_24_map"    fileName="fonts/arial_color_24_map.png"/>
    <Resource id="arial_color_48_glyphs" fileName="fonts/arial_color_48_glyphs.murl"/>
    <Resource id="arial_color_48_map"    fileName="fonts/arial_color_48_map.png"/>
    
    <!-- Graph resources -->
    <Resource id="graph_camera"         fileName="graph_camera.xml"/>
    <Resource id="graph_game_card"      fileName="graph_game_card.xml"/>
    <Resource id="graph_game_card_suit" fileName="graph_game_card_suit.xml"/>
    <Resource id="graph_game_screen"    fileName="graph_game_screen.xml"/>
    <Resource id="graph_materials"      fileName="graph_materials.xml"/>
    <Resource id="graph_textures"       fileName="graph_textures.xml"/>
    
    <!-- Graph instances -->
    <Instance graphResourceId="graph_materials"/>
    <Instance graphResourceId="graph_textures"/>
    <Instance graphResourceId="graph_camera"/>
    
</Package>

Der eigentliche Startpunkt für das Geschehen ist die graph_camera Instanz. Diese definiert in gewohnter Weise eine View mit einer Kamera und instanziert als Letztes den graph_game_screen Sub-Graphen. Eine Unterteilung der Sub-Graphen auf Dateiebene ist empfehlenswert, um in umfangreicheren Projekten den Überblick zu bewahren.

<?xml version="1.0" ?>
<!-- Copyright 2013 Spraylight GmbH -->
<Graph>
    <Namespace id="game_screen">
        
        <Timeline id="screen_timeline">
            <Transform controller.animationResourceId="game:anim_game_screen">
                
                <FixedParameters id="screen_parameters"
                controller.animationResourceId="game:anim_game_screen"/>
                <ParametersState parametersId="screen_parameters"/>
                
                <!-- Green playfield -->
                <Reference targetId="/materials/state_plain_color"/>
                <FixedParameters diffuseColor="0i, 100i, 0i, 255i"
                    parentParametersId="screen_parameters">
                    <PlaneGeometry posX="0" posY="0"
                    frameSizeX="1500" frameSizeY="768"/>
                </FixedParameters>
                
                <!-- Text material -->
                <Reference targetId="/materials/state_plain_tex_color"/>
                
                <!-- Title text -->
                <Reference targetId="/textures/arial_color_48"/>
                <TextGeometry id="title_text"
                posX="0" posY="340"
                fontResourceId="game:arial_color_48_glyphs"
                text="Pyramid Card Game"/>
                
                <!-- Info text -->
                <Reference targetId="/textures/arial_color_24"/>
                <TextGeometry id="info_text"
                posX="0" posY="-140"
                fontResourceId="game:arial_color_24_glyphs"/>
                
                <!-- Cards -->
                <Reference targetId="/materials/state_plain_front_tex_color"/>
                
                <TextureState textureId="/textures/tex_misc"/>
                <Instance cardName="black_joker" graphResourceId="game:graph_game_card"/>
                <Instance cardName="red_joker"   graphResourceId="game:graph_game_card"/>
                
                <Instance suitName="clubs"    graphResourceId="game:graph_game_card_suit"/>
                <Instance suitName="diamonds" graphResourceId="game:graph_game_card_suit"/>
                <Instance suitName="hearts"   graphResourceId="game:graph_game_card_suit"/>
                <Instance suitName="spades"   graphResourceId="game:graph_game_card_suit"/>
                
                <Button id="stack_button" depthOrder="1000"/>
                
            </Transform>
        </Timeline>

    </Namespace>
</Graph>

Unser Spielbildschirm beginnt mit einem <PlaneGeometry> Knoten für einen grünen Hintergrund und zwei <TextGeometry> Knoten um Textinformationen anzuzeigen.

Der letzte Block instanziert die Spielkarten. Um bei den Spielkarten eine Vorder- und Rückseite darstellen zu können, wird ein Material referenziert, welches das sogenannte Backface Culling des Grafik-Chips verwendet. Dieses Feature zeichnet nur die Vorderseite von Polygonen und wird im entsprechenden <Material> Knoten mit dem Attribut visibleFaces="FRONT_ONLY" aktiviert.

Jede Karte wird mit folgendem Sub-Graphen instanziert. Unser Beispiel beginnt mit der Instanz der black_joker und der red_joker Spielkarte.

Zu beachten ist, dass zuvor der zu den Karten passende <TextureState> gesetzt wird.

<?xml version="1.0" ?>
<!-- Copyright 2013 Spraylight GmbH -->
<Graph>
    
    <Namespace id="{cardName}" activeAndVisible="no">
        
        <Transform id="position">
            
            <Reference targetId="/textures/{cardName}"/>
            
            <SubState>
                <TextureState textureId="/textures/tex_misc"/>
                <Transform axisX="0" axisY="1" axisZ="0" angle="180 deg">
                    <Switch id="back_switch">
                        <Reference targetId="/textures/black_back"/>
                        <Reference targetId="/textures/red_back"/>
                    </Switch>
                </Transform>
            </SubState>
            
            <Button id="button" sizeX="90" sizeY="130"/>
            
        </Transform>
        
    </Namespace>
    
</Graph>

Hier wird die Vorder- und Rückseiten-Textur der Karteninstanz referenziert.Die Rückseitentextur ist an der vertikalen Achse um 180° gedreht, um dem Backface Culling gerecht zu werden.

Um alle Instanzen der vier Kartentypen zu erzeugen, wird ein weiterer Sub-Graph instanziert, welcher alle Karten mit gleicher Textur zusammenfasst.

<?xml version="1.0" ?>
<!-- Copyright 2013 Spraylight GmbH -->
<Graph>
    
    <TextureState textureId="/textures/tex_{suitName}"/>
    
    <Instance cardName="ace_of_{suitName}"   graphResourceId="game:graph_game_card"/>
    <Instance cardName="2_of_{suitName}"     graphResourceId="game:graph_game_card"/>
    <Instance cardName="3_of_{suitName}"     graphResourceId="game:graph_game_card"/>
    <Instance cardName="4_of_{suitName}"     graphResourceId="game:graph_game_card"/>
    <Instance cardName="5_of_{suitName}"     graphResourceId="game:graph_game_card"/>
    <Instance cardName="6_of_{suitName}"     graphResourceId="game:graph_game_card"/>
    <Instance cardName="7_of_{suitName}"     graphResourceId="game:graph_game_card"/>
    <Instance cardName="8_of_{suitName}"     graphResourceId="game:graph_game_card"/>
    <Instance cardName="9_of_{suitName}"     graphResourceId="game:graph_game_card"/>
    <Instance cardName="10_of_{suitName}"    graphResourceId="game:graph_game_card"/>
    <Instance cardName="jack_of_{suitName}"  graphResourceId="game:graph_game_card"/>
    <Instance cardName="queen_of_{suitName}" graphResourceId="game:graph_game_card"/>
    <Instance cardName="king_of_{suitName}"  graphResourceId="game:graph_game_card"/>
    
</Graph>

Somit haben wir mit überschaubarem Aufwand alle 54 Karteninstanzen mit Vorder- und Rückseiten Textur erstellt.

Applikation

Das klassische Kartenspiel Pyramiden Solitär legt 28 der Karten als drei Pyramiden auf und deckt die unterste Reihe auf. Die verbleibenden Karten liegen auf einem Stapel, von dem die oberste Karte aufgedeckt wird. Nun muss immer eine Karte, die um einen Wert höher oder niedriger ist, von der Pyramide auf die aufgedeckte Karte gelegt werden. Die Farben der Karten sind nicht relevant. Wenn keine passende Karte auf der Pyramide ist, kann eine neue Karte aus den verbleibenden Karten aufgedeckt werden.

Ziel ist es, alle Karten der Pyramide abzulegen. Das Spiel ist verloren, wenn keine passende Karte auf der Pyramide ist und alle verbleibenden Karten aufgedeckt wurden.

Aufbau einer Karteninstanz

Unser Spiel hat 54 Karteninstanzen, welche alle separat gesteuert werden. Um dies möglichst einfach zu erreichen, empfiehlt es sich eine Klasse zu implementieren welche genau eine Karte steuert. Diese Klasse wird dann pro Karte instanziert. Somit lässt sich eine Symmetrie zwischen den Logik-Klassen und den Sub-Graphen herstellen.

        class CardInstance : public Logic::GraphPositionInstance
        {
        public:
            CardInstance();
            virtual ~CardInstance();
            
            virtual Bool Init(Logic::INodeObserver* nodeObserver, const Graph::IRoot* root,
                              const String& path, SInt32 baseDepth);
            
            virtual void OnProcessTick(const Logic::IState* state);
            
            enum BackSideType
            {
                BLACK,
                RED
            };
            void SetBack(BackSideType backSide);
            
            void SetCardPosition(const Vector& position, Bool showFront, Real duration = 0);
            void MoveCardToPosition(const Vector& position, Bool showFront, Real duration);
            Bool IsMoving() const;
            Bool IsShowingFront() const;
            
            void EnableButton(Bool enable);
            Bool WasPressed() const;
            
        protected:
            Logic::ButtonNode mButton;
            Logic::SwitchNode mBackSwitch;
            
            Logic::BaseStepableObserver mStepableObserver;
            Logic::AnimationVector mPositionAnim;
            Logic::AnimationVector mRotationAnim;
            
            Bool mIsShowingFront;
        };

Eine Karteninstanz hat einen Namespace-Knoten und einen Transform-Knoten. Diese typische Konstellation wird von der Logic::GraphPositionInstance Basisklasse unterstützt, welche entsprechende Methoden zur Manipulation der Position und zum Aktivieren des Namespace-Knoten zur Verfügung stellt. Die virtuelle Init(…) Methode wird überladen, um unsere abgeleitete Klasse zu initialisieren.

Des Weiteren ist diese Basisklasse ein Stepable-Objekt, welches uns die Implementierung einer OnProcessTick(…) Methode erlaubt.

Anschließend sind einige Methoden implementiert, um Eigenschaften und Bewegung der Karte anzugeben bzw. um den aktuellen Zustand der Karte abzufragen.

Die Karteninstanz besitzt einen Logic::ButtonNode mButton Knoten für die Interaktion und einen Logic::SwitchNode mBackSwitch Knoten für die Textur der Kartenrückseite.

Die Karte sollen sich eigenständig animieren. Dazu werden ein Logic::AnimationVector mPositionAnim zum Positionieren und ein Logic::AnimationVector mRotationAnim zum Rotieren verwendet.

Da das Kartenobjekt im Gegensatz zur Prozessor Klasse keinen eingebauten StepableObserver besitzt, muss ein solcher hier mit Logic::BaseStepableObserver mStepableObserver instanziert werden.

Initialisierung einer Karteninstanz

Bool App::CardInstance::Init(Logic::INodeObserver* nodeObserver, const Graph::IRoot* root,
                             const String& path, SInt32 baseDepth)
{
    nodeObserver->Add(mButton.GetReference(root, path + "/button"));
    nodeObserver->Add(mBackSwitch.GetReference(root, path + "/back_switch"));
    
    if (!Logic::GraphPositionInstance::Init(nodeObserver, root, path, baseDepth))
    {
        return false;
    }
    
    mPositionAnim.AddKey(Real(0.0), Vector(Vector::ZERO_POSITION), IEnums::INTERPOLATION_EASE_OUT);
    mPositionAnim.AddKey(Real(1.0), Vector(Vector::ZERO_POSITION));
    
    mRotationAnim.AddKey(Real(0.0), Vector(), IEnums::INTERPOLATION_LINEAR);
    mRotationAnim.AddKey(Real(0.5), Vector(), IEnums::INTERPOLATION_EASE_OUT);
    mRotationAnim.AddKey(Real(1.0), Vector());
    
    mStepableObserver.Add(mPositionAnim);
    mStepableObserver.Add(mRotationAnim);
    
    SetBack(false);
    EnableButton(false);
    SetCardPosition(mPosition, false);
    mIsShowingFront = true;
    
    return true;
}

Zu Beginn stellt die Initialisierung die Referenzen für den Button-Knoten und den Switch-Knoten her.

(!) Anschließend wird die Init() Methode der Basisklasse aufgerufen. Dies ist unbedingt notwendig, um die Funktionalität der Basisklasse sicherzustellen.

Des Weiteren werden einige Animationsstützpunkte vorbereitet und die AnimationVector Instanzen dem StepableObserver hinzugefügt.

Im letzten Block werden noch einige Vorgabewerte gesetzt.

Abarbeiten einer Karteninstanz

void App::CardInstance::OnProcessTick(const Logic::IState* state)
{
    mStepableObserver.ProcessTick(state);
    
    if (mPositionAnim.IsOrWasRunning())
    {
        const Vector& currentPosition = mPositionAnim.GetCurrentValue();
        SetPosition(currentPosition);
    }
    
    if (mRotationAnim.IsOrWasRunning())
    {
        const Vector& currentRotation = mRotationAnim.GetCurrentValue();
        mTransformNode->SetRotation(currentRotation.x, currentRotation.y, currentRotation.z);
    }
}

(!) Die OnProcessTick(...) Methode muss die mStepableObserver.ProcessTick(state) Methode aufrufen damit der StepableObserver wie gewünscht funktioniert.

Anschließend werden die aktuellen Werte der AnimationVector Objekte ausgewertet und auf die entsprechenden Karteneigenschaften übertragen.

Aufbau der Logik

        class CardGameLogic : public Logic::BaseProcessor
        {
            ...
            
            enum States
            {
                STATE_IDLE = 0,
                STATE_DEAL,
                STATE_PLAY
            };
            Logic::EnumStateMachine<States>::Type mStateMachine;
            
            void OnEnterDeal(const Logic::IState* logicState);
            void OnProcessTickDeal(const Logic::IState* logicState);
            
            void OnEnterPlay(const Logic::IState* logicState);
            void OnProcessTickPlay(const Logic::IState* logicState);
            
            ...
            
            ObjectArray<CardInstance> mCards;
            
            Util::TT800 mRng;
            UInt32Array mCardDistribution;
            SInt32Array mCardsToDeal;
            
            SInt32Array mCardStack;
            SInt32Array mCardTray;
            SInt32Array mPlayfield;
            
            Logic::BaseTimeframe mGameStartTimeout;
            Logic::BaseTimeframe mGameEndTimeout;
            UInt32 mDealCount;
            
            UInt32 mNumberOfGamesPlayed;
        };

Hier ist nur ein Teil des gesamten Headers ersichtlich.

Zur Vereinfachung der Implementierung wird die StateMachine Klasse Logic::EnumStateMachine<States>::Type mStateMachine verwendet.

Die Karteninstanzen werden im Array ObjectArray<CardInstance> mCards erzeugt.

(!) Für Objekte muss die ObjectArray Template-Klasse anstatt der Array Template-Klasse verwendet werden.

Um Zeitspannen einfach zu messen, wird eine TimeFrame Klasse verwendet z.B. Logic::BaseTimeframe mGameStartTimeout.

Initialisierung der Logik

Bool App::CardGameLogic::OnInit(const Logic::IState* state)
{
    state->GetLoader()->UnloadPackage("startup");
    
    const Graph::IRoot* root = state->GetGraphRoot();
    
    AddGraphNode(mScreenTimeline.GetReference(root, "/game_screen/screen_timeline"));
    AddGraphNode(mGameInfoText.GetReference(root, "/game_screen/info_text"));
    AddGraphNode(mStackButton.GetReference(root, "/game_screen/stack_button"));
    
    mCards.Empty();
    AddSuit(root, "clubs");
    AddSuit(root, "diamonds");
    AddSuit(root, "hearts");
    AddSuit(root, "spades");
    mCards.Add().Init(GetNodeObserver(), root, "/game_screen/black_joker", 0);
    mCards.Add().Init(GetNodeObserver(), root, "/game_screen/red_joker", 0);
    
    if (!AreGraphNodesValid())
    {
        return false;
    }
    
    mScreenTimeline->Start(Real(0.0), Real(0.5));
    
    for (UInt32 i = 0; i < mCards.GetCount(); i++)
    {
        AddStepable(mCards[i]);
    }
    
    AddStepable(mGameStartTimeout);
    AddStepable(mGameEndTimeout);
    
    mStateMachine.Register<CardGameLogic>(STATE_DEAL, this, &CardGameLogic::OnProcessTickDeal,
                                          &CardGameLogic::OnEnterDeal);
    mStateMachine.Register<CardGameLogic>(STATE_PLAY, this, &CardGameLogic::OnProcessTickPlay,
                                          &CardGameLogic::OnEnterPlay);
    AddStepable(mStateMachine);
    
    return true;
}

Der erste Block initialisiert wie immer ein paar Referenzen auf entsprechende Knoten.

Der nächste Block initialisiert die Karteninstanzen und fügt diese dem mCards ObjectArray hinzu.

Anschließend werden alle Referenzen auf die Graphen Knoten überprüft und danach die mScreenTimeline gestartet, um den Spielbildschirm einzublenden.

Im nächsten Block werden alle Karteninstanzen dem Prozessor hinzugefügt, damit die OnProcessTick() Methoden der Karteninstanzen auch in jedem jeden Logik-Step aufgerufen werden.

TimeFrame

Die Timeframe-Klasse ist ein Stepable-Objekt und wird dem Prozessor mit AddStepable(mGameStartTimeout) hinzuzugefügt.

TimeFrames können eine Zeitspanne messen und deren Ablauf signalisieren. Die Zeitmessung beginnt mit dem Aufruf der Methode Start(). Die Methoden für die Zustandsabfragen der TimeFrame Klasse sind gleich benannt wie die Methoden von Timeline-Klassen, z.B. IsRunning(), WasRunning() etc.

StateMachine

Die Logik StateMachine Klasse ermöglicht es, Methoden in Abhängigkeit eines frei wählbaren Zustandes aufzurufen.

Jedem Zustand können bis zu vier Methoden zugeordnet werden. Eine OnProcessTick(), eine OnEnter(), eine OnLeave() und eine onFinishTick() Methode. Methoden sind nicht zugeordnet, wenn diese ein Null-Zeiger sind.

Die OnProcessTick() Methode des aktuellen Zustands wird in jedem Logik-Schritt aufgerufen. Wenn ein nächster Zustand gesetzt wird, wird im darauffolgenden Logik-Schritt die OnLeave() Methode des alten Zustandes und die OnEnter() Methode des neuen Zustandes aufgerufen.

In unserem Beispiel werden dem Zustand STATE_DEAL die Methoden OnProcessTickDeal() und OnEnterDeal() zugeordnet. Dem Zustand STATE_PLAY werden die Methoden OnProcessTickPlay() und OnEnterPlay() zugeordnet. Der STATE_IDLE ist der Initialzustand und benötigt in unserem Beispiel keine Methoden.

Die StateMachine Klasse ist ein Stepable-Objekt und wird dem Prozessor mit AddStepable(mStateMachine) hinzuzugefügt.

Zu beachten
Tipp: Stepable Instanzen werden in der hinzugefügten Reihenfolge abgearbeitet. Deshalb empfiehlt es sich, die StateMachine als Letztes hinzuzufügen, damit alle anderen Objekte bereits berechnet sind.
Achtung: Stepables werden im Prozessor grundsätzlich vor der OnProcessTick() Methode des Prozessors abgearbeitet.

Abarbeiten der Logik

void App::CardGameLogic::OnProcessTick(const Logic::IState* state)
{
    if (mScreenTimeline->WasRunning())
    {
        mStateMachine.SetNextState(STATE_DEAL);
    }

    Logic::IDeviceHandler* deviceHandler = state->GetDeviceHandler();
    if (deviceHandler->WasRawButtonPressed(RAWBUTTON_BACK))
    {
        deviceHandler->TerminateApp();
    }
}

Aufgrund des Einsatzes der StateMachine gestaltet sich die eigentliche OnProcessTick() Methode überraschend kurz.

Als Erstes wird, wenn der Spielbildschirm vollständig eingeblendet ist, die StateMachine auf den STATE_DEAL gesetzt und das Spiel beginnt.

Der zweite Block ist nur für Android Geräte notwendig, um die App beim Drücken der Zurücktaste auch wirklich zu beenden.

Aufbau der Spieldaten

Die Karteninstanzen sind im ObjectArray mCards und verbleiben dort für immer. Der gesamte Spielablauf speichert lediglich Indizes, welche auf die Karteninstanzen verweisen.

Der Spielablauf entscheidet letztlich nur, wo und wie sich die Karteninstanzen darstellen.

Die Methode ShuffleCards() erstellt mit dem RNG eine zufällige Folge von Kartenindizes in mCardsToDeal. Diese werden wie folgt verwendet:

Die ersten 28 Indizes werden nach mPlayfield verschoben, was die Pyramide darstellt.

Die restlichen Indizes werden nach mCardStack verschoben, was den Stapel der verbleibenden Karten darstellt.

Während des Spielverlaufes werden die entsprechenden Indizes von mPlayfield oder mCardStack nach mCardTray verschoben, was den aufgedeckten Stapel darstellt.

tut0203_card_game.png
Card Game Ausgabe Fenster

Abarbeiten der Applikationszustände

Bool App::CardGameApp::Configure(IEngineConfiguration* engineConfig, IFileInterface* fileInterface)
{
    const IPlatformConfiguration* platformConfig = engineConfig->GetPlatformConfiguration();
    IAppConfiguration* appConfig = engineConfig->GetAppConfiguration();
    
    engineConfig->SetProductName("CardGame");
    appConfig->SetWindowTitle("CardGame");
    
    if (platformConfig->IsTargetClassMatching(IEnums::TARGET_CLASS_COMPUTER))
    {
        if (platformConfig->IsOperatingSystemMatching(IEnums::OPERATING_SYSTEM_WINDOWS))
        {
            engineConfig->SetVideoApi(IEnums::VIDEO_API_DX9);
        }
        
        appConfig->SetDisplaySurfaceSize(1024, 768);
        appConfig->SetLockWindowAspectEnabled(true);
        appConfig->SetFullScreenEnabled(false);
    }
    
    if (platformConfig->IsTargetClassMatching(IEnums::TARGET_CLASS_HANDHELD))
    {
        appConfig->SetOrientationActive(true);
        appConfig->SetAutoRotationActive(true);
        appConfig->SetScreenOrientation(IEnums::SCREEN_ORIENTATION_LANDSCAPE_1);
        appConfig->SetAllowedScreenOrientations(appConfig->GetLandscapeOrientations());
        appConfig->SetMultiTouchActive(true);
    }
    
    engineConfig->SetDeactivatedAppRunState(IEnums::APP_RUN_STATE_PAUSED);
    
    return true;
}

Die Einstellung engineConfig->SetDeactivatedAppRunState(IEnums::APP_RUN_STATE_PAUSED) führt beim Aktivieren oder Deaktivieren des Fensters folgende Methode aus.

void App::CardGameLogic::OnRunStateChanged(const Logic::IState* state, IEnums::AppRunState currentState,
                                           IEnums::AppRunState previousState)
{
    if (mGameStartTimeout.IsOrWasRunning())
    {
        return;
    }
    
    if (currentState == IEnums::APP_RUN_STATE_PAUSED)
    {
        mGameInfoText->SetText("- Paused -");
        if (mStateMachine.GetCurrentState() == STATE_PLAY)
        {
            UpdatePlayfield(Real(0.0), true);
        }
    }
    else if (currentState == IEnums::APP_RUN_STATE_RUNNING)
    {
        mGameInfoText->SetText("- Play -");
        if (mStateMachine.GetCurrentState() == STATE_PLAY)
        {
            UpdatePlayfield(Real(0.3), false);
        }
    }
}

Diese Methode verdeckt die Karten beim Deaktivieren des Fensters und zeigt die Karten wieder an, wenn das Fenster wieder aktiviert wird.

Wenn die App im Sinne der Engine pausiert ist, dann steht die Logik-Zeit still und es wird kein OnProcessTick() ausgeführt. Das Rendering läuft jedoch normal weiter und es werden die OnProcessFrame() Methoden weiterhin aufgerufen.

Auf Mobilgeräten funktioniert der Mechanismus grundsätzlich genau gleich, wenn die App geschlossen wird. Geschlossene Apps werden jedoch von den gängigen mobilen Betriebssystemen still gelegt und somit findet auch kein Rendering und kein OnProcessFrame() während dieser Zeit statt.

Auf iOS Geräten muss in der info.plist der App folgender Eintrag gemacht werden:

<key>UIApplicationExitsOnSuspend</key>
    <false/>

Dieser Eintrag wird im Editor auch als "Application does not run in background" bezeichnet.

Übung

Das Spiel sollte bei der Rückkehr in den laufenden Betriebsmodus in der Pause verbleiben und eine Aufforderung zum Fortsetzen des Spiels mittels Touch oder Mausklick auf das Spielfeld implementieren.

Debug vs. Release

In der Debug-Konfiguration arbeiten wir gewöhnlicherweise direkt mit den Ressourcen in den dazugehörigen Verzeichnissen, z.B. game.murlres. Dies hat den Vorteil, dass während der Entwicklungsarbeit Änderungen in den Dateien unmittelbar getestet werden können ohne zuvor den resource_packer zu starten.

In der Release-Konfiguration hingegen werden alle Ressourcen generiert und mit dem resource_packer ein entsprechendes Paket erzeugt, z.B. game.murlpkg. Die dazu nötigen Skripte werden in einem zusätzlichen Build-Schritt in der Entwicklungsumgebung aufgerufen, um sicherzustellen, dass sich auch die aktuellen Ressourcen in den Paketen befinden.

In Xcode lassen sich solche zusätzlichen Build-Schritte nur pro Target einstellen und nicht in Abhängigkeit der Debug/Release Einstellung. Dies ist der Grund für die zwei Targets in unserem Beispiel:

  • CardGameDev Debug Build Konfiguration.
  • CardGame Release Build Konfiguration mit entsprechenden Skript Build-Schritt.

Alles Step

Unser Beispiel zeigt, dass so gut wie jedes Objekt das Bedürfnis nach Zeit hat, um sich in Abhängigkeit der Zeit zu präsentieren. Dieses wird durch Implementierung der OnProcessTick() Methode erreicht.

Somit muss auch sichergestellt sein, dass jede Instanz von OnProcessTick() Methode einmal und genau nur einmal pro Logik-Schritt aufgerufen wird.

In unserem Beispiel gibt es einen Prozessor dem etliche Stepable-Objekte hinzugefügt werden. Am umfangreichsten sind die Karteninstanzen, welche 54-mal hinzugefügt werden. Jede Karteninstanz implementiert wiederum einen StepableObserver, welcher die Animations-Instanzen innerhalb der Karteninstanz hinzugefügt hat.

(!) Ein robustes Programm sollte schon in der Entwurfsphase und während der Implementierung immer auch die Notwendigkeit der OnProcessTick() Aufrufe berücksichtigen.

(!) Ein klassischer Fehler ist es, wenn Stepable Instanzen gar nicht oder mehrfach aufgerufen werden.

Für interessierte ist hier der Ablauf pro Logik-Schritt skizziert:

CardGameLogic::Processor::ProcessTick()
CardGameLogic::StepableObserver::ProcessTick()
54 x CardInstance::Stepable::ProcessTick()
CardInstance::OnProcessTick()
CardInstance::mStepableObserver::ProcessTick()
CardInstance::mPositionAnim::LogicTimeline::ProcessTick()
CardInstance::mPositionAnim::LogicAnimation::OnEvaluate()
CardInstance::mRotationAnim::LogicTimeline::ProcessTick()
CardInstance:::mRotationAnim::LogicAnimation::OnEvaluate()
CardGameLogic::mGameStartTimeout::ProcessTick()
CardGameLogic::mGameEndTimeout::ProcessTick()
CardGameLogic::mStateMachine::ProcessTick()
CardGameLogic::mStateMachine::OnProcessTick()
CardGameLogic::On[ProcessTick][Enter]Deal() oder CardGameLogic::On[ProcessTick][Enter]Play()
CardGameLogic::OnProcessTick()

Hier zeigt sich, dass intern immer zuerst eine ProcessTick() Methode abgearbeitet wird. Anschließend wird eine entsprechende Interface-Methode wie z.B. OnProcessTick() oder OnEvaluate() aufgerufen.

(!) Grundsätzlich gilt: Finger weg von den ProcessTick() Methoden, wenn man nicht ganz genau weiß, was man tut. Applikationen sollten bevorzugt mit den OnProcessTick() und vielen weiteren On...() Methoden arbeiten. Software-Updates in der Engine können das interne Verhalten von Methoden verändern, das Verhalten der On...() Interface Methoden hingegen bleibt hingegen immer gleich.


Copyright © 2011-2024 Spraylight GmbH.