This is the Polish translation of In-Practice/2D-Game/Powerups article of learnopengl.com tutorial series.

Breakout jest bliski ukończenia, ale byłoby fajnie przynajmniej dodać jeszcze jedną mechanikę rozgrywki, aby nie był to przeciętny standardowy klon Breakout; a co z powerupami?

Chodzi o to, że ilekroć cegła jest zniszczona, cegła ma małą szansę na stworzenie bloku powerup. Taki blok będzie powoli opadał w dół i jeśli zderzy się z wiosłem gracza, pojawia się ciekawy efekt w zależności od rodzaju wzmocnienia. Na przykład, jeden powerup sprawia, że ​​wiosło jest większe, a inny powerup pozwala kulce przejść przez cegły. Mamy również kilka negatywnych bonusów, które wpływają na gracza w negatywny sposób.

Możemy zamodelować powerup jako w zasadzie GameObject z kilkoma dodatkowymi właściwościami. Dlatego definiujemy klasę PowerUp, która dziedziczy po GameObject i dodaje te dodatkowe właściwości do obiektu:

    const glm::vec2 SIZE(60, 20);
    const glm::vec2 VELOCITY(0.0f, 150.0f);

    class PowerUp : public GameObject 
    {
    public:
        // PowerUp State
        std::string Type;
        GLfloat     Duration;	
        GLboolean   Activated;
        // Constructor
        PowerUp(std::string type, glm::vec3 color, GLfloat duration, 
                glm::vec2 position, Texture2D texture) 
            : GameObject(position, SIZE, texture, color, VELOCITY), 
              Type(type), Duration(duration), Activated() 
        { }
    };  

Klasa PowerUp to tylko GameObject z dodatkowym stanem, więc możemy po prostu zdefiniować ją w jednym pliku nagłówkowym, który można znaleźć tutaj.

Każdy powerup definiuje swój typ jako ciąg znaków, posiada czas jego trwania i czy jest on obecnie aktywowany. W ramach Breakout będziemy mieli w sumie 4 pozytywne bonusy i 2 negatywne bonusy:

Wzmocnienia używane w BreakGL OpenGL

  • Speed: zwiększa prędkość piłki o 20%.
  • Sticky: gdy piłka zderza się z wiosłem, piłka zostaje przyklejona do niego, chyba że spacja zostanie ponownie naciśnięta. Pozwala to graczowi lepiej pozycjonować piłkę przed jej zwolnieniem.
  • Pass-Through: reakcje kolizji zostają wyłączone dla zniszczalnych bloków, dzięki czemu kula przechodzi przez wiele bloków.
  • Pad-Size-Increase: zwiększa szerokość wiosła o 50 pikseli.
  • Confuse: aktywuje efekt postprocessingu Confuse na krótki czas, dezorientując użytkownika.
  • Chaos: na krótki czas aktywuje efekt postprocessingu Chaos, co mocno dezorientuje użytkownika.

Poniżej znajdziesz tekstury o wysokiej jakości:

Podobnie jak w przypadku tekstur bloków poziomu, każda z dodanych tekstur jest w skali szarości. Dzięki temu kolory ulepszeń pozostają zrównoważone, gdy pomnożymy je przez wektor koloru.

Ponieważ bonusy mają stan, czas trwania i pewne efekty z nimi związane, chcielibyśmy śledzić wszystkie bonusy aktualnie aktywne w grze; przechowujemy je w wektorze:

    class Game {
        public:
            [...]
            std::vector<PowerUp>  PowerUps;
            [...]
            void SpawnPowerUps(GameObject &block);
            void UpdatePowerUps(GLfloat dt);
    };

Zdefiniowaliśmy również dwie funkcje zarządzania powerupami. SpawnPowerUps tworzy powerupy w miejscu danego bloku, a UpdatePowerUps zarządza wszystkimi bonusami aktualnie aktywnymi w grze.

Tworzenie PowerUp’ów

Za każdym razem, gdy bloczek jest niszczony, chcemy stworzyć powerup biorąc pod uwagę małą szansę na jego stworzenie. Ta funkcja to SpawnPowerUps i znajduje się w klasie Game:

    GLboolean ShouldSpawn(GLuint chance)
    {
        GLuint random = rand() % chance;
        return random == 0;
    }
    void Game::SpawnPowerUps(GameObject &block)
    {
        if (ShouldSpawn(75)) // 1 in 75 chance
            this->PowerUps.push_back(
                 PowerUp("speed", glm::vec3(0.5f, 0.5f, 1.0f), 0.0f, block.Position, tex_speed
             ));
        if (ShouldSpawn(75))
            this->PowerUps.push_back(
                PowerUp("sticky", glm::vec3(1.0f, 0.5f, 1.0f), 20.0f, block.Position, tex_sticky 
            );
        if (ShouldSpawn(75))
            this->PowerUps.push_back(
                PowerUp("pass-through", glm::vec3(0.5f, 1.0f, 0.5f), 10.0f, block.Position, tex_pass
            ));
        if (ShouldSpawn(75))
            this->PowerUps.push_back(
                PowerUp("pad-size-increase", glm::vec3(1.0f, 0.6f, 0.4), 0.0f, block.Position, tex_size    
            ));
        if (ShouldSpawn(15)) // Negative powerups should spawn more often
            this->PowerUps.push_back(
                PowerUp("confuse", glm::vec3(1.0f, 0.3f, 0.3f), 15.0f, block.Position, tex_confuse
            ));
        if (ShouldSpawn(15))
            this->PowerUps.push_back(
                PowerUp("chaos", glm::vec3(0.9f, 0.25f, 0.25f), 15.0f, block.Position, tex_chaos
            ));
    }  

Funkcja SpawnPowerUps tworzy nowy obiekt PowerUp w oparciu o daną szansę (1 na 75 dla normalnych powerupów i 1 na 15 dla negatywnych powerupów) i ustawia ich właściwości. Każdemu powerupowi nadawany jest określony kolor, aby uczynić go bardziej rozpoznawalnym dla użytkownika i czas trwania w sekundach w zależności od jego typu; czas trwania 0.0f oznacza, że ​​czas jego trwania jest nieskończony. Dodatkowo każde ulepszenie otrzymuje pozycję zniszczonego bloku i jedną z tekstur z poprzedniej sekcji.

Aktywacja PowerUp’ów

Następnie aktualizujemy funkcję DoCollisions, aby nie tylko sprawdzać kolizje cegieł i wiosła, ale także wszystkie kolizje między wiosłem, a każdym niezniszczonym PowerUp’em. Zwróć uwagę, że wywołujemy funkcję SpawnPowerUps, gdy tylko blok zostanie zniszczony.

    void Game::DoCollisions()
    {
        for (GameObject &box : this->Levels[this->Level].Bricks)
        {
            if (!box.Destroyed)
            {
                Collision collision = CheckCollision(*Ball, box);
                if (std::get<0>(collision)) // If collision is true
                {
                    // Destroy block if not solid
                    if (!box.IsSolid)
                    {
                        box.Destroyed = GL_TRUE;
                        this->SpawnPowerUps(box);
                    }
                    [...]
                }
            }
        }        
        [...] 
        for (PowerUp &powerUp : this->PowerUps)
        {
            if (!powerUp.Destroyed)
            {
                if (powerUp.Position.y >= this->Height)
                    powerUp.Destroyed = GL_TRUE;
                if (CheckCollision(*Player, powerUp))
                {	// Collided with player, now activate powerup
                    ActivatePowerUp(powerUp);
                    powerUp.Destroyed = GL_TRUE;
                    powerUp.Activated = GL_TRUE;
                }
            }
        }  
    }

W przypadku wszystkich bonusów, które nie zostały jeszcze zniszczone, sprawdzamy, czy powerup osiągnął dolną krawędź ekranu, czy też zderzył się z wiosłem. W obu przypadkach powerup jest niszczony, ale po zderzeniu z wiosłem jest również aktywowany.

Aktywacja bonusu jest dokonywana poprzez zmianę jego właściwości Activated na true i włączenie efektu powerup poprzez podaniu tej właściwości do funkcji ActivatePowerUp:

    void ActivatePowerUp(PowerUp &powerUp)
    {
        // Initiate a powerup based type of powerup
        if (powerUp.Type == "speed")
        {
            Ball->Velocity *= 1.2;
        }
        else if (powerUp.Type == "sticky")
        {
            Ball->Sticky = GL_TRUE;
            Player->Color = glm::vec3(1.0f, 0.5f, 1.0f);
        }
        else if (powerUp.Type == "pass-through")
        {
            Ball->PassThrough = GL_TRUE;
            Ball->Color = glm::vec3(1.0f, 0.5f, 0.5f);
        }
        else if (powerUp.Type == "pad-size-increase")
        {
            Player->Size.x += 50;
        }
        else if (powerUp.Type == "confuse")
        {
            if (!Effects->Chaos)
                Effects->Confuse = GL_TRUE; // Only activate if chaos wasn't already active
        }
        else if (powerUp.Type == "chaos")
        {
            if (!Effects->Confuse)
                Effects->Chaos = GL_TRUE;
        }
    } 

Celem ActivatePowerUp jest aktywacja efekt powerupu, jak to opisaliśmy na początku tego samouczka. Sprawdzamy rodzaj bonusu i odpowiednio zmieniamy stan gry. W celu uzyskania efektu sticky i pass-through zmieniamy również odpowiednio kolor wiosła i piłki, aby dać użytkownikowi pewne informacje zwrotne na temat tego, który efekt jest aktualnie aktywny.

Ponieważ efekty sticky i pass-through nieznacznie zmieniają logikę gry, przechowujemy ich efekt jako właściwość obiektu piłki; w ten sposób możemy zmienić logikę gry na podstawie tego, jaki efekt na piłkę jest obecnie aktywny. Jedyną zmianą jakiej dokonujemy w nagłówku BallObject jest dodanie tych dwóch właściwości. Dla kompletności zaktualizowany kod znajduje się poniżej:

Następnie możemy łatwo zaimplementować efekt sticky, nieznacznie aktualizując funkcję DoCollisions przy kolizji między piłką a wiosłem:

    if (!Ball->Stuck && std::get<0>(result))
    {
        [...]
        Ball->Stuck = Ball->Sticky;
    }

Tutaj ustawiamy właściwość Stuck piłki, która jest podobna do właściwości Sticky piłki. Jeśli efekt sticky zostanie aktywowany, piłka będzie w końcu przyklejała się do wiosła gracza, gdy się z nim zderzy; użytkownik musi ponownie nacisnąć spację, aby zwolnić piłkę.

Podobna niewielka zmiana dotyczy efektu pass-through w ramach tej samej funkcji DoCollisions. Jeśli właściwość PassThrough piłki jest ustawiona na true, nie wykonujemy żadnej kolizji na zniszczalnych klockach.

    Direction dir = std::get<1>(collision);
    glm::vec2 diff_vector = std::get<2>(collision);
    if (!(Ball->PassThrough && !box.IsSolid)) 
    {
        if (dir == LEFT || dir == RIGHT) // Horizontal collision
        {
            [...]
        }
        else 
        {
            [...]
        }
    }  

Pozostałe efekty aktywuje się po prostu modyfikując część stanu gry, np. prędkość piłki, rozmiar wiosła lub efekt obiektu PostProcesser.

Aktualizacja PowerUp’ów

Teraz pozostaje tylko upewnić się, że bonusy są w stanie poruszać się po ich stworzeniu i że są dezaktywowane, gdy tylko skończy się ich czas trwania; inaczej powerupy pozostaną aktywne na zawsze.

W ramach funkcji UpdatePowerUps przesuwamy bonusy w oparciu o ich prędkość i zmniejszamy ich czas trwania. Za każdym razem, gdy czas trwania powerupu zostaje zmniejszony do 0.0f, jego efekt zostaje dezaktywowany, a odpowiednie zmienne zostają ustawione na swój pierwotny stan.

    void Game::UpdatePowerUps(GLfloat dt)
    {
        for (PowerUp &powerUp : this->PowerUps)
        {
            powerUp.Position += powerUp.Velocity * dt;
            if (powerUp.Activated)
            {
                powerUp.Duration -= dt;

                if (powerUp.Duration <= 0.0f)
                {
                    // Remove powerup from list (will later be removed)
                    powerUp.Activated = GL_FALSE;
                    // Deactivate effects
                    if (powerUp.Type == "sticky")
                    {
                        if (!isOtherPowerUpActive(this->PowerUps, "sticky"))
                        {	// Only reset if no other PowerUp of type sticky is active
                            Ball->Sticky = GL_FALSE;
                            Player->Color = glm::vec3(1.0f);
                        }
                    }
                    else if (powerUp.Type == "pass-through")
                    {
                        if (!isOtherPowerUpActive(this->PowerUps, "pass-through"))
                        {	// Only reset if no other PowerUp of type pass-through is active
                            Ball->PassThrough = GL_FALSE;
                            Ball->Color = glm::vec3(1.0f);
                        }
                    }
                    else if (powerUp.Type == "confuse")
                    {
                        if (!isOtherPowerUpActive(this->PowerUps, "confuse"))
                        {	// Only reset if no other PowerUp of type confuse is active
                            Effects->Confuse = GL_FALSE;
                        }
                    }
                    else if (powerUp.Type == "chaos")
                    {
                        if (!isOtherPowerUpActive(this->PowerUps, "chaos"))
                        {	// Only reset if no other PowerUp of type chaos is active
                            Effects->Chaos = GL_FALSE;
                        }
                    }                
                }
            }
        }
        this->PowerUps.erase(std::remove_if(this->PowerUps.begin(), this->PowerUps.end(),
            [](const PowerUp &powerUp) { return powerUp.Destroyed && !powerUp.Activated; }
        ), this->PowerUps.end());
    }  

Możesz zobaczyć, że dla każdego efektu wyłączamy go, resetując odpowiednie elementy do ich oryginalnego stanu. Ustawiliśmy także właściwość Activated na false. Pod koniec UpdatePowerUps iterujemy po wektorze PowerUps i usuwamy każdy bonus, jeśli jest on zniszczony i dezaktywowany. Używamy funkcji remove_if z nagłówka algorithm, aby usunąć te elementy z uwzględnieniem predykatu lambda.

Funkcja remove_if przenosi wszystkie elementy, dla których predykat lambda ma wartość true, na koniec obiektu kontenera i zwraca iterator na początek tego usuniętego zakresu elementów. Funkcja erase tego kontenera przyjmuje następnie iterator i iterator końca wektora, aby usunąć wszystkie elementy między tymi dwoma iteratorami.

Może się zdarzyć, że gdy jeden z efektów jest aktywny, inne bonusy tego samego typu kolidują z wiosłem gracza. W takim przypadku mamy więcej niż 1 powerup danego typu, aktualnie aktywnego w wektorze PowerUps. Następnie, gdy jeden z tych bonusów zostanie dezaktywowany, nie chcemy wyłączać jego efektów, ponieważ inne bonusy tego samego typu mogą być nadal aktywne. Z tego powodu używamy funkcji IsOtherPowerUpActive w celu sprawdzenia, czy jest jeszcze aktywny inny bonus tego samego typu. Tylko wtedy, gdy funkcja zwróci false, dezaktywujemy powerup. W ten sposób czas trwania danego typu bonusu zostaje przedłużony do czasu jego ostatniego aktywowanego powerupu.

    GLboolean IsOtherPowerUpActive(std::vector<PowerUp> &powerUps, std::string type)
    {
        for (const PowerUp &powerUp : powerUps)
        {
            if (powerUp.Activated)
                if (powerUp.Type == type)
                    return GL_TRUE;
        }
        return GL_FALSE;
    }  

Funkcja po prostu sprawdza dla wszystkich aktywowanych powerup’ów, czy nadal są aktywne jakiekolwiek powerupy tego samego typu, a jeśli tak, zwraca GL_TRUE.

Ostatnią rzeczą do zrobienia jest renderowanie powerup’ów:

    void Game::Render()
    {
        if (this->State == GAME_ACTIVE)
        {
            [...]
            for (PowerUp &powerUp : this->PowerUps)
                if (!powerUp.Destroyed)
                    powerUp.Draw(*Renderer);
            [...]
        }
    }    

Połącz wszystkie te funkcje, a uzyskasz działający system powerup, który nie tylko sprawia, że ​​gra jest przyjemniejsza, ale także o wiele trudniejsza. Wygląda to mniej więcej tak:

Poniżej znajdziesz zaktualizowany kod klasy Game (tam też resetujemy wszystkie efekty powerup’ów po każdym zresetowaniu poziomu):