„DoubleBuffer“-Pattern

Dieser Beitrag ist ein Resultat aus meinen eigenen Erfahrungen, die ich jetzt gerade vor kurzem machen durfte. Bevor ich auf das eigentliche Problem eingehe, möchte ich vorher ein wenig Kontext schaffen um das Ganze ein wenig zu veranschaulichen. Ich verwende hier keine größeren Tricks oder speziellen Kniffe, sodass der Code für jeden verständlich sein sollte.

Als Grundgerüst soll uns eine schlanke Implementierung eines GameObjekts, kurz Unit, dienen. Dieses Unit hat einige wenige Eigenschaften wie z.B. Hp, Angriffskraft (Atk) und einen Namen.

class Unit
{
public:
    Unit(int _hp, int _atk, std::string _name) :
        m_Hp(_hp),
        m_Atk(_atk),
        m_Name(std::move(_name))
    {
        assert(_hp > 0 && _atk > 0 && !m_Name.empty());
    }

    int getHp() const { return m_Hp; }
    int getAtk() const { return m_Atk; }
    const std::string& getName() const { return m_Name; }

private:
    int m_Hp;
    int m_Atk;
    std::string m_Name;
};

Wer sich nun über die Zeile mit dem assert wundert, dem lege ich ans Herze sich darüber einmal zu informieren. Zugegeben, in diesem Zusammenhang wird man asserts wohl eher selten nutzen; da hier aber alles hardcoded umgesetzt wird, ist es durchaus brauchbar und erfüllt seinen Zweck.

Wir nutzen also eine relativ simple Klasse um einen epischen Kampf zwischen 2 Kontrahenten austragen zu lassen. Dazu implementieren wir noch folgende Methoden.

bool isAlive() const { return getHp() > 0; }
void attack(Unit& _enemy)
{
    assert(this != &_enemy);
    if (isAlive()) // dead units can't attack
    {
        _enemy.m_Hp -= getAtk();
    }
}

Die Regeln sind relativ einfach. Mit jeder Attacke werden dem Verteidiger die Angriffspunkte von den Lebenspunkten abgezogen. Hat am Ende einer Runde jemand 0 oder weniger Hp, ist das Spiel vorbei. Als zusätzliche Bedingung dürfen natürlich nur lebende Units angreifen. Diese scheinbar logische und auch triviale Regel ist unser Kernproblem, auf das ich im späteren Verlauf gerne weiter eingehen würde.

Doch zuerst implementieren wir unsere Spiellogik. Da ich hier die Komplexität möglichst gering halten möchte, wird das Ganze auch entsprechend trivial umgesetzt; denn wie gesagt geht es im Kern dieses Beitrags um die Bedingung, dass nur lebende Units angreifen können.

int main()
{
    Unit hero(100, 25, "Hero");
    Unit warrior(100, 25, "Warrior");
    
    while (hero.isAlive() && warrior.isAlive())
    {
        hero.attack(warrior);
        warrior.attack(hero);
    }
}

Mit dieser kurzen Logik können wir nun unseren Kampf simulieren. Um noch etwas mehr Feedback zu generiere, erstellen wir noch 2 print Funktionen.

const Unit* getWinner(const Unit& _first, const Unit& _second)
{
    if (_first.isAlive() && !_second.isAlive())
        return &_first;
    else if (_second.isAlive() && !_first.isAlive())
        return &_second;
    return nullptr;
}

void printHp(const Unit& _unit)
{
    std::cout << _unit.getName() << " has " << _unit.getHp() << " Hp left." << std::endl;
}

int main()
{
    Unit hero(100, 25, "Hero");
    Unit warrior(100, 25, "Warrior");
    
    while (hero.isAlive() && warrior.isAlive())
    {
        hero.attack(warrior);
        warrior.attack(hero);
        printHp(hero);
        printHp(warrior);
    }

    if (auto winner = getWinner(hero, warrior))
        std::cout << "The winner is: " << winner->getName() << std::endl;
    else
        std::cout << "There is no winner." << std::endl;
}

Es werden jede Runde die restlichen Hp jedes Kontrahenten ausgegeben und am Ende der Gewinner ermittelt (natürlich äußerst simpel). Wollen wir uns nun mal die Ausgabe näher anschauen.

Hero has 75 Hp left.
Warrior has 75 Hp left.
Hero has 50 Hp left.
Warrior has 50 Hp left.
Hero has 25 Hp left.
Warrior has 25 Hp left.
Hero has 25 Hp left.
Warrior has 0 Hp left.
The winner is: Hero
Die Hp beider Streiter nimmt wie erwartet kontinuierlich jede Runde um 25 ab, bis am Ende der Held den Sieg davon trägt. Nur, moment, ist das eigentlich fair? Wenn wir im Code die Reihenfolge der Attacken ändern, sodass der Warrior vor dem Helden angreift, dann würde nun der Warrior siegen.
Spinnt man dieses Szenario noch ein wenig weiter und überträgt es auf ein reelles Projekt, in dem Units dynamisch erstellt werden, so wird man schnell verstehen, dass das schlecht zu kontrollieren ist. Vor allem wenn es in den Bereich PvP geht wird es die Spieler schnell vergraulen, wenn derjenige einen Vorteil hat, dessen Unit zuerst erstellt wurde.
Eine Lösung wäre nun, die Bedingung innerhalb der attack Methode zu entfernen; aber ist das wirklich dass, was wir wollen? Es ist durchaus richtig, dass ein totes Unit nicht angreifen kann.
Es gilt sich darüber bewusst zu werden, dass innerhalb eines Durchlaufs der „GameLoop“ prinzipiell alles zeitgleich passiert. Alles was passiert sollte grundlegend unabhängig davon sein, wann es innerhalb dieses Durchlaufs passiert. Diese Herangehensweise verschafft eine gewisse Konsistenz und Robustheit, die sich später bezahlt macht. Die generelle Überlegung ist also, dass alle Eigenschaften, die sich innerhalb eines Durchlaufs verändern können, innerhalb des derzeitigen Durchlaufs unverändert bleiben.
Das mag ein wenig verwirrend klingen, heißt aber grundlegend nur, dass Eigenschaften nicht innerhalb des Durchlaufs verändert werden dürfen, sondern erst danach.
Damit hätten wir auch schon eine naive Lösung für unser Problem. Wir erstellen uns eine m_CurrentHp Eigenschaft und implementieren eine endTick() Methode um den beiden Units zu signalisieren, dass sie jetzt die Eigenschaften modifizieren können.
Die finale Klasse sieht nun folgendermaßen aus.
class Unit
{
public:
    Unit(int _hp, int _atk, std::string _name) :
        m_CurrentHp(_hp),
        m_Hp(_hp),
        m_Atk(_atk),
        m_Name(std::move(_name))
    {
        assert(_hp > 0 && _atk > 0 && !m_Name.empty());
    }

    int getHp() const { return m_Hp; }
    int getAtk() const { return m_Atk; }
    const std::string& getName() const { return m_Name; }
    
    bool isAlive() const { return getHp() > 0; }
    void attack(Unit& _enemy)
    {
        assert(this != &_enemy);
        if (isAlive())   // dead units can't attack
        {
            _enemy.m_CurrentHp -= getAtk();
        }
    }
    
    void endTick()
    {
        m_Hp = m_CurrentHp;
    }
    
private:
    int m_CurrentHp;
    int m_Hp;
    int m_Atk;
    std::string m_Name;
};

Nun müssen wir lediglich noch innerhalb unserer „GameLoop“ die endTick() Methode aufrufen. Das machen wir am Besten vor der Ausgabe, da diese prinzipiell unsere visuelle Darstellung ist und davor das updaten der Units abgeschlossen sein muss.

while (hero.isAlive() && warrior.isAlive())
{
    hero.attack(warrior);
    warrior.attack(hero);
        
    hero.endTick();
    warrior.endTick();
        
    printHp(hero);
    printHp(warrior);
}

Lassen wir nun diesem epischen Kampf seinen Lauf, erhalten wir folgende Ausgabe:

Hero has 75 Hp left.
Warrior has 75 Hp left.
Hero has 50 Hp left.
Warrior has 50 Hp left.
Hero has 25 Hp left.
Warrior has 25 Hp left.
Hero has 0 Hp left.
Warrior has 0 Hp left.
There is no winner.

Wie erwartet, beide Kontrahenten sind besiegt! Damit wäre unser Problem gelöst.

Dieses Pattern lässt sich prinzipiell immer da anwenden, wo Eigenschaften über einen update Prozess hinweg unverändert bleiben sollen. Ein Sonderfall, wo es eher schwierig wird ist das Bewegen und die Kollisionskontrolle, da hier die Gegenseite der Kollision ein direktes Feedback benötigt, wie weit sie sich bewegen darf.

Eine weitere Einschränkung ist, dass es nun schwierig wird ein direktes Feedback über den reell zugefügten Schaden zu generieren. Stellt man sich die folgende Situation vor.

Unser Held hat noch 10 Hp
Kämpfer 1 fügt ihm 2 Schaden zu -> 8 Hp übrig
Kämpfer 2 fügt ihm 10 Schaden zu -> -2 Hp übrig
Heiler heilt den Helden um 1 Hp -> -1 Hp übrig

In vielen Spielen werden Erfahrungspunkte anhand des reell zugefügten Schadens verteilt. Hier in diesem Beispiel hatte der Held 10 Hp, es wurden aber insgesamt 12 Schadenspunkte verursacht. Zusätzlich wurde der Held noch um 1 Hp geheilt, was den effektiven Hp Pool auf 11 vergrößern würde. Dennoch haben wir hier ein Ungleichgewicht, aufgrund dessen nicht 100%ig ersichtlich ist, wie viel Schaden tatsächlich reell zugefügt wurde. Allerdings sind solche Fälle dann doch eher die Ausnahme, sodass meiner Meinung nach die Vorteile dieser Methode überwiegen. Dennoch sollte man sich dieser Probleme bewusst sein und sich Gedanken machen, ob und in wie weit dies dem persönlichen Nutzen zusagt oder nicht.

Wie mit jedem Pattern auch, gilt es also abzuwägen und nicht blind zu nutzen.

Einen bekannten Anwendungsfall für dieses Pattern gibt es übrigens beim Zeichnen der einzelnen Szenen in den Backbuffer. Meistens werden 2 oder mehrere dieser Buffer verwendet, wodurch immer auf einer aktuell nicht sichtbaren „Leinwand“ gezeichnet werden kann und der Nutzer vor dem Bildschirm keine Zwischenzustände zu sehen bekommen.

Ich hoffe das Thema auf verständliche Art und Weise vorgestellt zu haben und wäre über jedwede Art des Feedbacks froh.

Zum Schluss dann noch einmal der komplette Code des Projekts:

#include <iostream>
#include <string>
#include <cassert>

class Unit
{
public:
    Unit(int _hp, int _atk, std::string _name) :
        m_CurrentHp(_hp),
        m_Hp(_hp),
        m_Atk(_atk),
        m_Name(std::move(_name))
    {
        assert(_hp > 0 && _atk > 0 && !m_Name.empty());
    }

    int getHp() const { return m_Hp; }
    int getAtk() const { return m_Atk; }
    const std::string& getName() const { return m_Name; }
    
    bool isAlive() const { return getHp() > 0; }
    void attack(Unit& _enemy)
    {
        assert(this != &_enemy);
        if (isAlive())   // dead units can't attack
        {
            _enemy.m_CurrentHp -= getAtk();
        }
    }
    
    void endTick()
    {
        m_Hp = m_CurrentHp;
    }
    
private:
    int m_CurrentHp;
    int m_Hp;
    int m_Atk;
    std::string m_Name;
};

const Unit* getWinner(const Unit& _first, const Unit& _second)
{
    if (_first.isAlive() && !_second.isAlive())
        return &_first;
    else if (_second.isAlive() && !_first.isAlive())
        return &_second;
    return nullptr;
}

void printHp(const Unit& _unit)
{
    std::cout << _unit.getName() << " has " << _unit.getHp() << " Hp left." << std::endl;
}

int main()
{
    Unit hero(100, 25, "Hero");
    Unit warrior(100, 25, "Warrior");
    
    while (hero.isAlive() && warrior.isAlive())
    {
        hero.attack(warrior);
        warrior.attack(hero);
        
        hero.endTick();
        warrior.endTick();
        
        printHp(hero);
        printHp(warrior);
    }

    if (auto winner = getWinner(hero, warrior))
        std::cout << "The winner is: " << winner->getName() << std::endl;
    else
        std::cout << "There is no winner." << std::endl;
}

 

Schreibe einen Kommentar

Deine E-Mail-Adresse wird nicht veröffentlicht. Erforderliche Felder sind mit * markiert.