5.2 Asteroids

A cena de nosso Asteroids será composta pelos seguintes objetos:

  • Uma nave espacial, formada por GL_TRIANGLES;
  • Asteroides, formados por GL_TRIANGLE_FAN;
  • Tiros, formados por GL_TRIANGLE_FAN;
  • Estrelas de fundo, formadas por GL_POINTS.

Como nas aplicações feitas até agora, trabalharemos somente com gráficos 2D. As coordenadas de todos os objetos do jogo serão especificadas no chamado NDC (espaço normalizado do dispositivo). Como vimos na seção 4.3, para que as primitivas sejam renderizadas, as coordenadas em NDC devem estar dentro do volume de visão canônico, que é um cubo de \((-1, -1, -1)\) a \((1, 1, 1)\). Também vimos que coordenadas em NDC são mapeadas para o espaço da janela, de modo que o ponto \((-1,-1)\) é mapeado para o canto inferior esquerdo do viewport, e \((1,1)\) é mapeado para o canto superior direito, de acordo com o especificado em glViewport. A figura 5.5 ilustra o posicionamento de objetos da cena recortados pela região visível do NDC.

Objetos de cena na região visível do NDC.

Figura 5.5: Objetos de cena na região visível do NDC.

No jogo Asteroids original, a nave se movimenta pela tela enquanto a câmera virtual permanece fixa. Quando a nave sai dos limites da tela, reaparece no lado oposto. No nosso projeto, a nave se manterá fixa no centro da tela, enquanto todo o resto se moverá ao seu redor. O espaço será finito como no Asteroids original, e terá o tamanho da região que vai de \((-1,-1)\) a \((1,1)\). Se um asteroide sair do lado esquerdo da tela, reaparecerá no lado direito (observe que isso acontece na figura 5.5).

Um truque simples para obter o efeito de replicação do espaço é renderizar a cena nove vezes, uma vez para célula de uma grade 3x3 na qual apenas a célula do meio corresponde à região de \((-1,-1)\) a \((1,1)\). Isso é ilustrado na figura 5.6:

Replicando a cena em torno da região visível do NDC.

Figura 5.6: Replicando a cena em torno da região visível do NDC.

Não é necessário replicar os objetos que não saem da tela, como a nave. No nosso caso, os tiros também não serão replicados e deixarão de existir assim que saírem da tela.

Embora esse truque de replicação de cena funcione bem para este jogo simples, em cenas mais complexas é recomendável fazer testes de proximidade para descartar os objetos que estão fora da área visível. Isso evita processamento desnecessário no pipeline gráfico.

Organização do projeto

Nosso jogo possui vários objetos de cena, e portanto possui vários VBOs, VAOs e variáveis de propriedades desses objetos. Precisamos pensar bem em como organizar tudo isso. O código pode ficar bastante confuso se definirmos tudo na classe OpenGLWindow como fizemos nos projetos anteriores.

Classes

Para organizar melhor o projeto, separaremos os elementos de cena do jogo nas seguintes classes:

  • Ship: classe que representa a nave espacial (VAO, VBO e atributos como translação, orientação e velocidade).

  • StarLayers: classe que gerencia as camadas de estrelas usadas para fazer o efeito de paralaxe24 de fundo. StarLayers contém um arranjo de objetos do tipo StarLayer, sendo que cada StarLayer define o VBO de pontos de uma camada de estrelas.

  • Bullets: classe que gerencia os tiros. A classe contém uma lista de instâncias de uma estrutura Bullet, sendo que cada Bullet representa as propriedades de um tiro (translação, velocidade, etc). Todos os tiros compartilham um mesmo VBO definido em Bullets.

  • Asteroids: classe que gerencia os asteroides. Asteroids contém uma lista de instâncias de uma estrutura Asteroid, sendo que cada Asteroid define o VBO e propriedades de um asteroide.

As classes Ship, StarLayers, Bullets e Asteroids contêm suas próprias funções membro initializeGL, paintGL e terminateGL que serão chamadas nas funções membro respectivas de OpenGLWindow.

Definiremos também uma classe GameData para permitir o compartilhamento de dados de estado do jogo entre OpenGLWindow e as outras classes.

Arquivos

O diretório de projeto abcg/examples/asteroids terá a seguinte estrutura:

asteroids/
│   asteroids.cpp
│   asteroids.hpp
│   bullets.cpp
│   bullets.hpp
│   CMakeLists.txt
│   gamedata.hpp
│   main.cpp
│   openglwindow.hpp
│   openglwindow.cpp
│   ship.cpp
│   ship.hpp
│   starlayers.cpp
│   starlayers.hpp
│
└───assets/
    │   Inconsolata-Medium.ttf
    │   objects.frag
    │   objects.vert    
    │   stars.frag
    └   stars.vert    

O subdiretório assets contém arquivos de recursos utilizados no jogo:

  • O arquivo Inconsolata-Medium.ttf é a fonte Inconsolata utilizada na mensagem “Game Over” e “You Win.” O arquivo pode ser baixado ou copiado de abcg/abcg/assets (ou substitua por sua fonte favorita!).
  • Os arquivos stars.vert e stars.frag contêm o código-fonte do vertex shader e fragment shader utilizados para renderizar as estrelas.
  • Os arquivos objects.vert e objects.frag contêm o código-fonte do vertex shader e fragment shader utilizados em todos os outros objetos: nave, asteroides e tiros.

Poderíamos continuar definindo os shaders através de strings, mas o projeto fica mais organizado desta nova forma.

Importante

Sempre que um projeto da ABCg é configurado pelo CMake, o diretório assets (se existir) é copiado para build/bin/proj, onde proj é o nome do projeto.

Em todas as vezes que um arquivo de assets for modificado, é necessário limpar o diretório build para forçar a cópia de assets para build/bin/proj na próxima compilação. Isso pode ser feito das seguintes maneiras:

  • Removendo o diretório build antes da compilação;
  • No Visual Studio Code, usando o comando “CMake: Clean Rebuild” da paleta de comandos (Ctrl+Shift+P) antes da compilação;
  • Construindo o projeto através de build.sh/build.bat.

Se você precisar editar um shader várias vezes, deixe-o como uma string como fizemos nos projetos anteriores. Transforme-o em um asset apenas quando o shader estiver pronto e não for mais editado.

Observação

Quando o projeto é compilado para WebAssembly, o conteúdo de assets é transformado em um arquivo .data no diretório public. Assim, os arquivos resultantes de um projeto chamado proj serão:

  • proj.data: arquivo de recursos (assets);
  • proj.js: arquivo JavaScript que deve ser chamado pelo html;
  • proj.wasm: binário WebAssembly.

Configuração inicial

  1. Em abcg/examples, crie o subdiretório asteroids.

  2. No arquivo abcg/examples/CMakeLists.txt, inclua a linha add_subdirectory(asteroids).

  3. Crie o arquivo abcg/examples/asteroids/CMakeLists.txt com o seguinte conteúdo:

    project(asteroids)
    add_executable(${PROJECT_NAME} main.cpp openglwindow.cpp asteroids.cpp
                                   bullets.cpp ship.cpp starlayers.cpp)
    enable_abcg(${PROJECT_NAME})
  4. Crie todos os arquivos .cpp e .hpp (de asteroids.cpp até starlayers.cpp). Por enquanto os arquivos ficarão vazios.

  5. Crie o subdiretório assets e baixe/copie a fonte .ttf. Crie também os arquivos .frag e .vert. Vamos editá-los em seguida.

main.cpp

Não há nada de realmente novo no conteúdo de main.cpp. Apenas desativaremos o contador de FPS e o botão de tela cheia. O código ficará assim:

#include <fmt/core.h>

#include "abcg.hpp"
#include "openglwindow.hpp"

int main(int argc, char **argv) {
  try {
    abcg::Application app(argc, argv);

    auto window{std::make_unique<OpenGLWindow>()};
    window->setOpenGLSettings({.samples = 4});
    window->setWindowSettings({.width = 600,
                               .height = 600,
                               .showFPS = false,
                               .showFullscreenButton = false,
                               .title = "Asteroids"});
    app.run(std::move(window));
  } catch (const abcg::Exception &exception) {
    fmt::print(stderr, "{}\n", exception.what());
    return -1;
  }
  return 0;
}

gamedata.hpp

Neste arquivo definiremos uma estrutura GameData que descreve o estado atual do jogo e o estado dos dispositivos de entrada:

#ifndef GAMEDATA_HPP_
#define GAMEDATA_HPP_

#include <bitset>

enum class Input { Right, Left, Down, Up, Fire };
enum class State { Playing, GameOver, Win };

struct GameData {
  State m_state{State::Playing};
  std::bitset<5> m_input;  // [fire, up, down, left, right]
};

#endif
  • m_state pode ser:
    • State::Playing: quando a aplicação está em modo de jogo, com a nave respondendo aos comandos do jogador;
    • State::GameOver: quando o jogador perdeu. Nesse caso a nave não é exibida e não responde aos comandos do jogador;
    • State::Win: quando o jogador ganhou. A nave também não é exibida nesse estado.
  • m_input é uma máscara de bits de eventos de estado dos dispositivos de entrada. Por exemplo, o bit 0 corresponde a Input::Right e está setado enquando o usuário pressiona a seta para a direita, ou a tecla D. Esse estado é atualizado pela função membro OpenGLWindow::handleEvent que veremos adiante.

A classe OpenGLWindow manterá uma instância de GameData que será compartilhada com outras classes (Ship, Bullets, Asteroids, etc) sempre que elas precisarem ler ou modificar o estado do jogo.

objects.vert

Esse é o shader utilizado na renderização da nave, asteroides e tiros. O conteúdo será como a seguir:

#version 410

layout(location = 0) in vec2 inPosition;

uniform vec4 color;
uniform float rotation;
uniform float scale;
uniform vec2 translation;

out vec4 fragColor;

void main() {
  float sinAngle = sin(rotation);
  float cosAngle = cos(rotation);
  vec2 rotated = vec2(inPosition.x * cosAngle - inPosition.y * sinAngle,
                      inPosition.x * sinAngle + inPosition.y * cosAngle);

  vec2 newPosition = rotated * scale + translation;
  gl_Position = vec4(newPosition, 0, 1);
  fragColor = color;
}

Observe que os vértices só possuem um atributo inPosition do tipo vec2. Esse atributo corresponde à posição \((x,y)\) do vértice. A saída do vertex shader é uma cor RGBA definida pela variável uniforme color. Isso significa que, usando esse shader, todos os vértices terão a mesma cor.

O código de main é similar ao do vertex shader do projeto regularpolygons, mas dessa vez a posição é modificada não apenas por um fator de escala e translação, mas também por uma rotação. As linhas 13 a 16 fazem com que a posição inPosition seja rodada pelo ângulo rotation (em radianos) no sentido anti-horário. O resultado é uma nova posição rotated que é então transformada pela escala e translação.

Em capítulos futuros, veremos a teoria das transformações geométricas e os passos necessários para se chegar à expressão das linhas 15 e 16.

Observação

Todos os objetos do jogo são desenhados em tons de cinza, mas não há nada nos shaders que impeça que utilizemos cores. O aspecto preto e branco do jogo é só uma escolha artística para lembrar o antigo Asteroids do arcade.

objects.frag

O conteúdo desse fragment shader que acompanha objects.vert é o mesmo dos projetos anteriores. A cor de entrada é copiada para a cor de saída:

#version 410

in vec4 fragColor;

out vec4 outColor;

void main() { outColor = fragColor; }

Estrelas

As estrelas serão desenhadas como pontos (GL_POINTS) e usarão os shaders stars.vert e stars.frag definidos a seguir.

stars.vert

#version 410

layout(location = 0) in vec2 inPosition;
layout(location = 1) in vec3 inColor;

uniform vec2 translation;
uniform float pointSize;

out vec4 fragColor;

void main() {
  gl_PointSize = pointSize;
  gl_Position = vec4(inPosition.xy + translation, 0, 1);
  fragColor = vec4(inColor, 1);
}

Os atributos de entrada são uma posição \((x,y)\) (inPosition) e uma cor RGB (inColor). Em main, a cor de entrada é copiada para o atributo de saída (fragColor) como uma cor RGBA onde A é 1. A posição do ponto é deslocada por translation, e o tamanho do ponto é definido por pointSize.

stars.frag

#version 410

in vec4 fragColor;

out vec4 outColor;

void main() {
  float intensity = 1.0 - length(gl_PointCoord - vec2(0.5)) * 2.0;
  outColor = fragColor * intensity;
}

O processamento principal deste shader ocorre na definição da variável intensity. Para compreendermos o que está acontecendo, lembre-se primeiro que o tamanho de um ponto (gl_PointSize) é dado em pixels, e tamanhos maiores que 1 fazem com que o pipeline renderize um quadrado centralizado na posição de cada ponto. O fragment shader explora esse fato para exibir um gradiente radial no quadrado de modo a simular o formato circular de uma estrela. A variável embutida gl_PointCoord contém as coordenadas do fragmento dentro do quadrado. Na configuração padrão, \((0,0)\) é o canto superior esquerdo, e \((1,1)\) é o canto inferior direito (figura 5.8).

Quadrado gerado em torno de um ponto de `GL_POINTS`, e coordenadas de `gl_PointCoord` dentro do quadrado formado.

Figura 5.8: Quadrado gerado em torno de um ponto de GL_POINTS, e coordenadas de gl_PointCoord dentro do quadrado formado.

A expressão length(gl_PointCoord - vec2(0.5)) calcula a distância euclidiana até o centro do quadrado. Na direção em \(x\) e \(y\), essa distância está no intervalo \([0,0.5]\). A distância é convertida em uma intensidade de luz armazenada em intensity, sendo que a intensidade é máxima (1) no centro do quadrado. A cor de saída é multiplicada por essa intensidade. Se o quadrado for branco, o resultado será como o mostrado na figura 5.9).

Gradiente radial produzido por `stars.frag` no quadrado de um ponto definido com `GL_POINTS`.

Figura 5.9: Gradiente radial produzido por stars.frag no quadrado de um ponto definido com GL_POINTS.

Atualizando openglwindow.hpp

Para a implementação das estrelas, precisamos definir em OpenGLWindow o identificador dos shaders m_starsProgram e a instância de StarLayers. O código atualizado ficará como a seguir:

#ifndef OPENGLWINDOW_HPP_
#define OPENGLWINDOW_HPP_

#include <imgui.h>

#include <random>

#include "abcg.hpp"
#include "asteroids.hpp"
#include "bullets.hpp"
#include "ship.hpp"
#include "starlayers.hpp"

class OpenGLWindow : public abcg::OpenGLWindow {
 protected:
  void handleEvent(SDL_Event& event) override;
  void initializeGL() override;
  void paintGL() override;
  void paintUI() override;
  void resizeGL(int width, int height) override;
  void terminateGL() override;

 private:
  GLuint m_starsProgram{};
  GLuint m_objectsProgram{};

  int m_viewportWidth{};
  int m_viewportHeight{};

  GameData m_gameData;

  Ship m_ship;
  StarLayers m_starLayers;

  abcg::ElapsedTimer m_restartWaitTimer;

  ImFont* m_font{};

  std::default_random_engine m_randomEngine;

  void restart();
  void update();
};

#endif

Atualizando openglwindow.cpp

Precisamos atualizar também as funções membro de OpenGLWindow:

  • Em OpenGLWindow::initializeGL, inclua o seguinte código para compilar os novos shaders:

    // Create program to render the stars
    m_starsProgram = createProgramFromFile(getAssetsPath() + "stars.vert",
                                           getAssetsPath() + "stars.frag");
  • Em OpenGLWindow::restart, inclua a chamada a initializeGL de StarLayers junto com a chamada de initializeGL de Ship:

    m_starLayers.initializeGL(m_starsProgram, 25);
    m_ship.initializeGL(m_objectsProgram);
  • Em OpenGLWindow::update, chame a função update de StarLayers depois da chamada de update de Ship, assim:

    m_ship.update(m_gameData, deltaTime);
    m_starLayers.update(m_ship, deltaTime);
  • Em OpenGLWindow::paintGL, chame paintGL de StarLayers antes de paintGL de Ship, assim:

    m_starLayers.paintGL();
    m_ship.paintGL(m_gameData);
  • Por fim, modifique OpenGLWindow::terminateGL da seguinte forma:

    void OpenGLWindow::terminateGL() {
      abcg::glDeleteProgram(m_starsProgram);
      abcg::glDeleteProgram(m_objectsProgram);
    
      m_ship.terminateGL();
      m_starLayers.terminateGL();
    }

starlayers.hpp

A definição da classe StarLayers ficará assim:

#ifndef STARLAYERS_HPP_
#define STARLAYERS_HPP_

#include <array>
#include <random>

#include "abcg.hpp"
#include "gamedata.hpp"
#include "ship.hpp"

class OpenGLWindow;

class StarLayers {
 public:
  void initializeGL(GLuint program, int quantity);
  void paintGL();
  void terminateGL();

  void update(const Ship &ship, float deltaTime);

 private:
  friend OpenGLWindow;

  GLuint m_program{};
  GLint m_pointSizeLoc{};
  GLint m_translationLoc{};

  struct StarLayer {
    GLuint m_vao{};
    GLuint m_vbo{};

    float m_pointSize{};
    int m_quantity{};
    glm::vec2 m_translation{glm::vec2(0)};
  };

  std::array<StarLayer, 5> m_starLayers;

  std::default_random_engine m_randomEngine;
};

#endif

Nas linhas 28 a 35 é definida StarLayer. A estrutura contém o VBO e VAO dos pontos que formam uma camada de estrelas, o tamanho (m_pointSize) e quantidade (m_quantity) de pontos, e um fator de translação (m_translation) utilizado para deslocar todos os pontos da camada (isto é, todos os vértices do VBO).

Na linha 37 é definido um arranjo de cinco instâncias de StarLayer, pois renderizaremos cinco camadas de estrelas.

starlayers.cpp

O arquivo começa com a definição de StarLayers::initializeGL:

#include "starlayers.hpp"

#include <cppitertools/itertools.hpp>

void StarLayers::initializeGL(GLuint program, int quantity) {
  terminateGL();

  // Start pseudo-random number generator
  m_randomEngine.seed(
      std::chrono::steady_clock::now().time_since_epoch().count());

  m_program = program;
  m_pointSizeLoc = abcg::glGetUniformLocation(m_program, "pointSize");
  m_translationLoc = abcg::glGetUniformLocation(m_program, "translation");

  auto &re{m_randomEngine};
  std::uniform_real_distribution<float> distPos(-1.0f, 1.0f);
  std::uniform_real_distribution<float> distIntensity(0.5f, 1.0f);

  for (auto &&[index, layer] : iter::enumerate(m_starLayers)) {
    layer.m_pointSize = 10.0f / (1.0f + index);
    layer.m_quantity = quantity * (static_cast<int>(index) + 1);
    layer.m_translation = glm::vec2(0);

    std::vector<glm::vec3> data(0);
    for ([[maybe_unused]] auto i : iter::range(0, layer.m_quantity)) {
      data.emplace_back(distPos(re), distPos(re), 0);
      data.push_back(glm::vec3(1) * distIntensity(re));
    }

    // Generate VBO
    abcg::glGenBuffers(1, &layer.m_vbo);
    abcg::glBindBuffer(GL_ARRAY_BUFFER, layer.m_vbo);
    abcg::glBufferData(GL_ARRAY_BUFFER, data.size() * sizeof(glm::vec3),
                       data.data(), GL_STATIC_DRAW);
    abcg::glBindBuffer(GL_ARRAY_BUFFER, 0);

    // Get location of attributes in the program
    GLint positionAttribute{abcg::glGetAttribLocation(m_program, "inPosition")};
    GLint colorAttribute{abcg::glGetAttribLocation(m_program, "inColor")};

    // Create VAO
    abcg::glGenVertexArrays(1, &layer.m_vao);

    // Bind vertex attributes to current VAO
    abcg::glBindVertexArray(layer.m_vao);

    abcg::glBindBuffer(GL_ARRAY_BUFFER, layer.m_vbo);
    abcg::glEnableVertexAttribArray(positionAttribute);
    abcg::glVertexAttribPointer(positionAttribute, 2, GL_FLOAT, GL_FALSE,
                                sizeof(glm::vec3) * 2, nullptr);
    abcg::glEnableVertexAttribArray(colorAttribute);
    abcg::glVertexAttribPointer(colorAttribute, 3, GL_FLOAT, GL_FALSE,
                                sizeof(glm::vec3) * 2,
                                reinterpret_cast<void *>(sizeof(glm::vec3)));
    abcg::glBindBuffer(GL_ARRAY_BUFFER, 0);

    // End of binding to current VAO
    abcg::glBindVertexArray(0);
  }
}

O laço da linha 20 itera sobre cada elemento de m_starLayers. A expressão na linha 21 faz com que os pontos tenham tamanho 10 na 1ª camada, 5 na 2ª camada, 2.5 na 3ª camada, e assim sucessivamente. Na linha 22, a quantidade de pontos é dobrada a cada camada.

Na linha 25 é criado um arranjo data com dados dos pontos da camada. Os dados ficarão intercalados no formato \(\{x,y,0,r,g,b,x,y,0,r,g,b,\dots\}\), onde \((x,y,0)\) é a posição do ponto, e \((r,g,b)\) é a cor do ponto. Dentro do laço, as coordenadas \(x\) e \(y\) de cada ponto são escolhidas de forma aleatória dentro do intervalo \([-1,1]\). A cor de cada ponto é um tom de cinza escolhido aleatoriamente do intervalo \([0.5,1]\).

Os dados de data são copiados para o VBO através de glBufferData na linha 34.

Observe nas linhas 48 a 56 como é feito o mapeamento do VBO com os atributos inPosition (do tipo vec2) e inColor (do tipo vec4) do vertex shader. O stride do VBO é sizeof(glm::vec3) * 2 (isto é, dois vec3). Na linha 55, o deslocamento no início do VBO é sizeof(glm::vec3) (isto é, apenas um vec3). O cast de tipo é necessário porque o parâmetro de deslocamento é do tipo const void * (é assim por razões históricas).

A definição de StarLayers::paintGL ficará como a seguir:

void StarLayers::paintGL() {
  abcg::glUseProgram(m_program);

  abcg::glEnable(GL_BLEND);
  abcg::glBlendFunc(GL_ONE, GL_ONE);

  for (const auto &layer : m_starLayers) {
    abcg::glBindVertexArray(layer.m_vao);
    abcg::glUniform1f(m_pointSizeLoc, layer.m_pointSize);

    for (const auto i : {-2, 0, 2}) {
      for (const auto j : {-2, 0, 2}) {
        abcg::glUniform2f(m_translationLoc, layer.m_translation.x + j,
                          layer.m_translation.y + i);

        abcg::glDrawArrays(GL_POINTS, 0, layer.m_quantity);
      }
    }

    abcg::glBindVertexArray(0);
  }

  abcg::glDisable(GL_BLEND);

  abcg::glUseProgram(0);
}

Observe que os pontos são desenhados com o modo de mistura de cor habilitado. Na linha 67, a definição da função de mistura com fatores GL_ONE faz com que as cores produzidas pelo fragment shader sejam somadas com as cores atuais do framebuffer. Isso produz um efeito cumulativo de intensidade da luz quando estrelas de camadas diferentes são renderizadas na mesma posição.

Os laços aninhados nas linhas 73 e 74 produzem índices i e j que são usados em layer.m_translation para replicar o desenho das estrelas em uma grade 3x3 em torno da região visível do NDC, como vimos no início da seção.

Na linha 85, o modo de mistura de cor é desabilitado para não afetar a renderização dos outros objetos de cena que são totalmente opacos.

Em StarLayers::terminateGL são liberados os VBOs e VAOs de todas as instâncias de StarLayer:

void StarLayers::terminateGL() {
  for (auto &layer : m_starLayers) {
    abcg::glDeleteBuffers(1, &layer.m_vbo);
    abcg::glDeleteVertexArrays(1, &layer.m_vao);
  }
}

Em StarLayers::update, a translação (m_translation) de cada camada é atualizada de acordo com a velocidade da nave. Se a nave está indo para a frente, então a camada de estrelas deve ir para trás: por isso a subtração de ship.m_velocity. A velocidade é multiplicada por um fator de escala layerSpeedScale para fazer com que a primeira camada seja mais rápida que a segunda, e assim sucessivamente para produzir o efeito de paralaxe.

Nas linhas 103 a 106 há uma série de condicionais que testam se os pontos saíram dos limites da região visível do NDC. Se sim, são deslocados para o lado oposto.

void StarLayers::update(const Ship &ship, float deltaTime) {
  for (auto &&[index, layer] : iter::enumerate(m_starLayers)) {
    const auto layerSpeedScale{1.0f / (index + 2.0f)};
    layer.m_translation -= ship.m_velocity * deltaTime * layerSpeedScale;

    // Wrap-around
    if (layer.m_translation.x < -1.0f) layer.m_translation.x += 2.0f;
    if (layer.m_translation.x > +1.0f) layer.m_translation.x -= 2.0f;
    if (layer.m_translation.y < -1.0f) layer.m_translation.y += 2.0f;
    if (layer.m_translation.y > +1.0f) layer.m_translation.y -= 2.0f;
  }
}

Nesse momento, o jogo ficará como a seguir (link original):

O código pode ser baixado deste link.

Asteroides

Para incluir a implementação dos asteroides, vamos primeiramente atualizar OpenGLWindow.

Atualizando openglwindow.hpp

Adicione a definição de m_asteroids junto às definições dos outros objetos (m_ship e m_starLayers), assim:

Asteroids m_asteroids;
Ship m_ship;
StarLayers m_starLayers;

Atualizando openglwindow.cpp

  • Em OpenGLWindow::restart, chame o initializeGL de m_asteroids junto com a chamada de initializeGL dos objetos anteriores:

    m_starLayers.initializeGL(m_starsProgram, 25);
    m_ship.initializeGL(m_objectsProgram);
    m_asteroids.initializeGL(m_objectsProgram, 3);
  • Em OpenGLWindow::update, chame o update de m_asteroids após o update de m_ship:

    m_ship.update(m_gameData, deltaTime);
    m_starLayers.update(m_ship, deltaTime);
    m_asteroids.update(m_ship, deltaTime);
  • Em OpenGLWindow::paintGL, chame o paintGL de m_asteroids logo após o paintGL de m_starLayers:

    m_starLayers.paintGL();
    m_asteroids.paintGL();
    m_ship.paintGL(m_gameData);
  • Em OpenGLWindow::terminateGL, chame o terminateGL de m_asteroids junto com o terminateGL dos outros objetos:

    m_asteroids.terminateGL();
    m_ship.terminateGL();
    m_starLayers.terminateGL();
Observação

A ordem em que a função paintGL de cada objeto é chamada é importante porque o objeto renderizado por último será desenhado sobre os anteriores que já foram desenhados antes no framebuffer.

Essa forma de renderizar os objetos na ordem do mais distante para o mais próximo é chamada de “algoritmo do pintor” pois é similar ao modo como um pintor desenha sobre uma tela: primeiro é desenhado o fundo (elemento mais distante) e então sobre ele são desenhados os elementos mais próximos.

asteroids.hpp

A definição da classe Asteroids ficará assim:

#ifndef ASTEROIDS_HPP_
#define ASTEROIDS_HPP_

#include <list>
#include <random>

#include "abcg.hpp"
#include "gamedata.hpp"
#include "ship.hpp"

class OpenGLWindow;

class Asteroids {
 public:
  void initializeGL(GLuint program, int quantity);
  void paintGL();
  void terminateGL();

  void update(const Ship &ship, float deltaTime);

 private:
  friend OpenGLWindow;

  GLuint m_program{};
  GLint m_colorLoc{};
  GLint m_rotationLoc{};
  GLint m_translationLoc{};
  GLint m_scaleLoc{};

  struct Asteroid {
    GLuint m_vao{};
    GLuint m_vbo{};

    float m_angularVelocity{};
    glm::vec4 m_color{1};
    bool m_hit{false};
    int m_polygonSides{};
    float m_rotation{};
    float m_scale{};
    glm::vec2 m_translation{glm::vec2(0)};
    glm::vec2 m_velocity{glm::vec2(0)};
  };

  std::list<Asteroid> m_asteroids;

  std::default_random_engine m_randomEngine;
  std::uniform_real_distribution<float> m_randomDist{-1.0f, 1.0f};

  Asteroids::Asteroid createAsteroid(glm::vec2 translation = glm::vec2(0),
                                     float scale = 0.25f);
};

#endif

Entre as linhas 30 a 42 é definida a estrutura Asteroid. Cada asteroide tem seu próprio VAO e VBO. Além disso possui uma velocidade angular, uma cor, número de lados, ângulo de rotação, escala, translação, vetor de velocidade, e um flag m_hit que indica se o asteroide foi acertado por um tiro.

Na linha 44 é definida uma lista de Asteroid. O número de elementos dessa lista será modificado de acordo com os asteroides que forem acertados pelos tiros. Cada vez que um asteroide for acertado, ele será retirado da lista. Entretanto, se o asteroide for grande, simularemos que ele foi quebrado em vários pedaços e então asteroides menores serão inseridos na lista.

A função membro createAsteroid declarada nas linhas 49 e 50 será utilizada para criar um novo asteroide para ser inserido na lista m_asteroids. O fator de escala (parâmetro scale) permitirá configurar o tamanho do novo asteroide.

asteroids.cpp

O arquivo começa com a definição de Asteroids::initializeGL:

#include "asteroids.hpp"

#include <cppitertools/itertools.hpp>
#include <glm/gtx/fast_trigonometry.hpp>

void Asteroids::initializeGL(GLuint program, int quantity) {
  terminateGL();

  // Start pseudo-random number generator
  m_randomEngine.seed(
      std::chrono::steady_clock::now().time_since_epoch().count());

  m_program = program;
  m_colorLoc = abcg::glGetUniformLocation(m_program, "color");
  m_rotationLoc = abcg::glGetUniformLocation(m_program, "rotation");
  m_scaleLoc = abcg::glGetUniformLocation(m_program, "scale");
  m_translationLoc = abcg::glGetUniformLocation(m_program, "translation");

  // Create asteroids
  m_asteroids.clear();
  m_asteroids.resize(quantity);

  for (auto &asteroid : m_asteroids) {
    asteroid = createAsteroid();

    // Make sure the asteroid won't collide with the ship
    do {
      asteroid.m_translation = {m_randomDist(m_randomEngine),
                                m_randomDist(m_randomEngine)};
    } while (glm::length(asteroid.m_translation) < 0.5f);
  }
}

Na linha 21, a lista de asteroides é iniciada com uma quantidade quantity de objetos do tipo Asteroid. Essa lista é então iterada no laço das linhas 23 a 31 e o conteúdo de cada asteroide é modificado por Asteroids::createAsteroid. No laço da linha 27 é escolhida uma posição aleatória para o asteroide, mas que esteja longe o suficiente da nave: não queremos que o jogo comece com o asteroide colidindo com a nave!

A definição de Asteroids::paintGL ficará como a seguir:

void Asteroids::paintGL() {
  abcg::glUseProgram(m_program);

  for (const auto &asteroid : m_asteroids) {
    abcg::glBindVertexArray(asteroid.m_vao);

    abcg::glUniform4fv(m_colorLoc, 1, &asteroid.m_color.r);
    abcg::glUniform1f(m_scaleLoc, asteroid.m_scale);
    abcg::glUniform1f(m_rotationLoc, asteroid.m_rotation);

    for (auto i : {-2, 0, 2}) {
      for (auto j : {-2, 0, 2}) {
        abcg::glUniform2f(m_translationLoc, asteroid.m_translation.x + j,
                          asteroid.m_translation.y + i);

        abcg::glDrawArrays(GL_TRIANGLE_FAN, 0, asteroid.m_polygonSides + 2);
      }
    }

    abcg::glBindVertexArray(0);
  }

  abcg::glUseProgram(0);
}

A lista m_asteroids é iterada e cada asteroide é renderizado 9 vezes (em uma grade 3x3), como fizemos com as estrelas.

Em Asteroids::terminateGL são liberados os VBOs e VAOs dos asteroides:

void Asteroids::terminateGL() {
  for (auto asteroid : m_asteroids) {
    abcg::glDeleteBuffers(1, &asteroid.m_vbo);
    abcg::glDeleteVertexArrays(1, &asteroid.m_vao);
  }
}

Vamos agora à definição de Asteroids::update:

void Asteroids::update(const Ship &ship, float deltaTime) {
  for (auto &asteroid : m_asteroids) {
    asteroid.m_translation -= ship.m_velocity * deltaTime;
    asteroid.m_rotation = glm::wrapAngle(
        asteroid.m_rotation + asteroid.m_angularVelocity * deltaTime);
    asteroid.m_translation += asteroid.m_velocity * deltaTime;

    // Wrap-around
    if (asteroid.m_translation.x < -1.0f) asteroid.m_translation.x += 2.0f;
    if (asteroid.m_translation.x > +1.0f) asteroid.m_translation.x -= 2.0f;
    if (asteroid.m_translation.y < -1.0f) asteroid.m_translation.y += 2.0f;
    if (asteroid.m_translation.y > +1.0f) asteroid.m_translation.y -= 2.0f;
  }
}

Na linha 68, a translação (m_translation) de cada asteroide é modificada pelo vetor de velocidade da nave, como fizemos com as estrelas. Na linha 69, a rotação é atualizada de acordo com a velocidade angular. Na linha 71, a translação do asteroide é modificada novamente, mas agora considerando a velocidade do próprio asteroide.

As condicionais das linhas 74 a 77 fazem com que as coordenadas de m_translation permaneçam no intervalo circular de -1 a 1.

Em Asteroids::createAsteroid é criada uma nova instância de Asteroid:

Asteroids::Asteroid Asteroids::createAsteroid(glm::vec2 translation,
                                              float scale) {
  Asteroid asteroid;

  auto &re{m_randomEngine};  // Shortcut

  // Randomly choose the number of sides
  std::uniform_int_distribution<int> randomSides(6, 20);
  asteroid.m_polygonSides = randomSides(re);

  // Choose a random color (actually, a grayscale)
  std::uniform_real_distribution<float> randomIntensity(0.5f, 1.0f);
  asteroid.m_color = glm::vec4(1) * randomIntensity(re);

  asteroid.m_color.a = 1.0f;
  asteroid.m_rotation = 0.0f;
  asteroid.m_scale = scale;
  asteroid.m_translation = translation;

  // Choose a random angular velocity
  asteroid.m_angularVelocity = m_randomDist(re);

  // Choose a random direction
  glm::vec2 direction{m_randomDist(re), m_randomDist(re)};
  asteroid.m_velocity = glm::normalize(direction) / 7.0f;

  // Create geometry
  std::vector<glm::vec2> positions(0);
  positions.emplace_back(0, 0);
  const auto step{M_PI * 2 / asteroid.m_polygonSides};
  std::uniform_real_distribution<float> randomRadius(0.8f, 1.0f);
  for (const auto angle : iter::range(0.0, M_PI * 2, step)) {
    const auto radius{randomRadius(re)};
    positions.emplace_back(radius * std::cos(angle), radius * std::sin(angle));
  }
  positions.push_back(positions.at(1));

  // Generate VBO
  abcg::glGenBuffers(1, &asteroid.m_vbo);
  abcg::glBindBuffer(GL_ARRAY_BUFFER, asteroid.m_vbo);
  abcg::glBufferData(GL_ARRAY_BUFFER, positions.size() * sizeof(glm::vec2),
                     positions.data(), GL_STATIC_DRAW);
  abcg::glBindBuffer(GL_ARRAY_BUFFER, 0);

  // Get location of attributes in the program
  GLint positionAttribute{abcg::glGetAttribLocation(m_program, "inPosition")};

  // Create VAO
  abcg::glGenVertexArrays(1, &asteroid.m_vao);

  // Bind vertex attributes to current VAO
  abcg::glBindVertexArray(asteroid.m_vao);

  abcg::glBindBuffer(GL_ARRAY_BUFFER, asteroid.m_vbo);
  abcg::glEnableVertexAttribArray(positionAttribute);
  abcg::glVertexAttribPointer(positionAttribute, 2, GL_FLOAT, GL_FALSE, 0,
                              nullptr);
  abcg::glBindBuffer(GL_ARRAY_BUFFER, 0);

  // End of binding to current VAO
  abcg::glBindVertexArray(0);

  return asteroid;
}

Na linha 101 é escolhida uma velocidade angular aleatória do intervalo \([-1, 1]\) (em radianos por segundo).

Na linha 105 é escolhido um vetor unitário aleatório para definir a velocidade do asteroide. As componentes do vetor são divididas por 7 de modo que cada asteroide inicie com uma velocidade de 1/7 unidades de espaço por segundo.

O restante do código cria a geometria do asteroide. O código é bem parecido com o que foi utilizado para criar o polígono regular no projeto regularpolygons. A diferença é que agora usamos a equação paramétrica do círculo com raio \(r\)

\[ \begin{eqnarray} x&=&r cos(t),\\ y&=&r sin(t), \end{eqnarray} \]

e selecionamos um \(r\) aleatório do intervalo \([0.8, 1]\) para cada vértice do polígono.

Nesse momento, o jogo ficará como a seguir (link original):

O código pode ser baixado deste link.

Tiros e colisões

Neste momento o jogo ainda não tem detecção de colisões e tiros. É isso que implementaremos agora.

Atualizando openglwindow.hpp

Adicione a definição de m_bullets junto à definição dos outros objetos:

Asteroids m_asteroids;
Bullets m_bullets;
Ship m_ship;
StarLayers m_starLayers;

Adicione também a declaração das seguintes funções membro adicionais de OpenGLWindow:

void checkCollisions();
void checkWinCondition();
  • checkCollisions será utilizada para verificar as colisões;
  • checkWinCondition será utilizada para verificar se o jogador ganhou (isto é, se não há mais asteroides).

Atualizando openglwindow.cpp

  • Em OpenGLWindow::restart, inclua a chamada à função initializeGL de m_bullets junto com initializeGL dos objetos anteriores, assim:

    m_starLayers.initializeGL(m_starsProgram, 25);
    m_ship.initializeGL(m_objectsProgram);
    m_asteroids.initializeGL(m_objectsProgram, 3);
    m_bullets.initializeGL(m_objectsProgram);
  • Em OpenGLWindow::update, chame update de m_bullets em qualquer lugar após a chamada de update de m_ship. Por exemplo:

    m_ship.update(m_gameData, deltaTime);
    m_starLayers.update(m_ship, deltaTime);
    m_asteroids.update(m_ship, deltaTime);
    m_bullets.update(m_ship, m_gameData, deltaTime);

    Além disso, inclua a seguinte condicional após os updates para calcular as colisões e verificar a condição de vitória:

    if (m_gameData.m_state == State::Playing) {
      checkCollisions();
      checkWinCondition();
    }
  • Em OpenGLWindow::paintGL, chame paintGL de m_bullets logo após a chamada de paintGL de m_asteroids:

    m_starLayers.paintGL();
    m_asteroids.paintGL();
    m_bullets.paintGL();
    m_ship.paintGL(m_gameData);
  • Em OpenGLWindow::terminateGL, chame terminateGL de m_bullets junto com terminateGL dos outros objetos:

    m_asteroids.terminateGL();
    m_bullets.terminateGL();    
    m_ship.terminateGL();
    m_starLayers.terminateGL();

Vamos agora definir OpenGLWindow::checkCollisions como a seguir:

void OpenGLWindow::checkCollisions() {
  // Check collision between ship and asteroids
  for (const auto &asteroid : m_asteroids.m_asteroids) {
    const auto asteroidTranslation{asteroid.m_translation};
    const auto distance{
        glm::distance(m_ship.m_translation, asteroidTranslation)};

    if (distance < m_ship.m_scale * 0.9f + asteroid.m_scale * 0.85f) {
      m_gameData.m_state = State::GameOver;
      m_restartWaitTimer.restart();
    }
  }

  // Check collision between bullets and asteroids
  for (auto &bullet : m_bullets.m_bullets) {
    if (bullet.m_dead) continue;

    for (auto &asteroid : m_asteroids.m_asteroids) {
      for (const auto i : {-2, 0, 2}) {
        for (const auto j : {-2, 0, 2}) {
          const auto asteroidTranslation{asteroid.m_translation +
                                         glm::vec2(i, j)};
          const auto distance{
              glm::distance(bullet.m_translation, asteroidTranslation)};

          if (distance < m_bullets.m_scale + asteroid.m_scale * 0.85f) {
            asteroid.m_hit = true;
            bullet.m_dead = true;
          }
        }
      }
    }

    // Break asteroids marked as hit
    for (const auto &asteroid : m_asteroids.m_asteroids) {
      if (asteroid.m_hit && asteroid.m_scale > 0.10f) {
        std::uniform_real_distribution<float> m_randomDist{-1.0f, 1.0f};
        std::generate_n(std::back_inserter(m_asteroids.m_asteroids), 3, [&]() {
          const glm::vec2 offset{m_randomDist(m_randomEngine),
                                 m_randomDist(m_randomEngine)};
          return m_asteroids.createAsteroid(
              asteroid.m_translation + offset * asteroid.m_scale * 0.5f,
              asteroid.m_scale * 0.5f);
        });
      }
    }

    m_asteroids.m_asteroids.remove_if(
        [](const Asteroids::Asteroid &a) { return a.m_hit; });
  }
}

Nas linhas 173 a 183 é feita a detecção de colisão entre a nave e cada asteroide:

  // Check collision between ship and asteroids
  for (auto &asteroid : m_asteroids.m_asteroids) {
    auto asteroidTranslation{asteroid.m_translation};
    auto distance{glm::distance(m_ship.m_translation, asteroidTranslation)};

    if (distance < m_ship.m_scale * 0.9f + asteroid.m_scale * 0.85f) {
      m_gameData.m_state = State::GameOver;
      m_restartWaitTimer.restart();
    }
  }

A detecção de colisão é feita através da comparação da distância euclidiana (glm::distance) entre as coordenadas de translação dos objetos. Essas coordenadas podem ser consideradas como a posição do centro dos objetos na cena (como ilustrado pelos pontos \(P_s\) e \(P_a\) na figura 5.10:

Detecção de colisão entre nave e asteroide através da comparação de distância entre círculos.

Figura 5.10: Detecção de colisão entre nave e asteroide através da comparação de distância entre círculos.

\(P_s\) e \(P_a\) também podem ser considerados como centros de círculos. O fator de escala de cada objeto corresponde ao raio do círculo (\(r_s\) e \(r_a\)). Assim, podemos detectar a colisão através de uma simples comparação da distância \(|P_s-P_a|\) com a soma dos fatores de escala. Só há colisão se a distância for menor ou igual a \(r_s+r_a\). Esse tipo de teste é bem mais simples e eficiente (embora menos preciso) do que comparar a interseção entre os triângulos que formam os objetos.

Note, na linha 179, que \(r_s\) e \(r_a\) são de fato os fatores m_scale de cada objeto, mas multiplicados por 0.9f (para a nave) e 0.85f (para o asteroide). Isso é feito para diminuir um pouco o raio dos círculos e fazer com que exista uma tolerância de sobreposição antes de ocorrer a colisão. Veja, na figura 5.10, que dessa forma os objetos não ficam inscritos nos círculos. Os valores 0.9 e 0.85 foram determinados empiricamente.

Nas linhas 185 a 203 é feita a detecção de colisão entre os tiros e os asteroides:

  // Check collision between bullets and asteroids
  for (auto &bullet : m_bullets.m_bullets) {
    if (bullet.m_dead) continue;

    for (auto &asteroid : m_asteroids.m_asteroids) {
      for (const auto i : {-2, 0, 2}) {
        for (const auto j : {-2, 0, 2}) {
          const auto asteroidTranslation{asteroid.m_translation +
                                         glm::vec2(i, j)};
          const auto distance{
              glm::distance(bullet.m_translation, asteroidTranslation)};

          if (distance < m_bullets.m_scale + asteroid.m_scale * 0.85f) {
            asteroid.m_hit = true;
            bullet.m_dead = true;
          }
        }
      }
    }

A interseção é calculada novamente através da comparação da distância entre círculos. Note que os testes de distância são feitos dentro de laços aninhados parecidos com os que foram utilizados para replicar a renderização dos asteroides na grade 3x3 em torno da região visível do viewport. De fato, o teste de colisão de um tiro com um asteroide precisa considerar essa replicação, pois um asteroide que está saindo à esquerda do viewport pode ser atingido por um tiro no lado oposto, à direita.

Se um tiro acertou um asteroide, o m_hit do asteroide e o m_dead do tiro tornam-se true.

Observe agora as linhas 205 a 217:

    // Break asteroids marked as hit
    for (const auto &asteroid : m_asteroids.m_asteroids) {
      if (asteroid.m_hit && asteroid.m_scale > 0.10f) {
        std::uniform_real_distribution<float> m_randomDist{-1.0f, 1.0f};
        std::generate_n(std::back_inserter(m_asteroids.m_asteroids), 3, [&]() {
          const glm::vec2 offset{m_randomDist(m_randomEngine),
                                 m_randomDist(m_randomEngine)};
          return m_asteroids.createAsteroid(
              asteroid.m_translation + offset * asteroid.m_scale * 0.5f,
              asteroid.m_scale * 0.5f);
        });
      }
    }

Nesse código, os asteroides com m_hit == true são testados para verificar se são suficientemente grandes (m_scale > 0.10f). Se sim, três novos asteroides menores são criados e inseridos na lista m_asteroids.m_asteroids.

Nas linhas 219 a 220, os asteroides que estavam com m_hit == true são removidos da lista:

    m_asteroids.m_asteroids.remove_if(
        [](const Asteroids::Asteroid &a) { return a.m_hit; });

Isso é tudo para a detecção de colisão. Vamos agora à definição de OpenGLWindow::checkWinCondition, que ficará como a seguir:

void OpenGLWindow::checkWinCondition() {
  if (m_asteroids.m_asteroids.empty()) {
    m_gameData.m_state = State::Win;
    m_restartWaitTimer.restart();
  }
}

A vitória ocorre quando a lista de asteroides está vazia. Nesse caso, o estado do jogo é modificado para State::Win e o temporizador m_restartWaitTimer é reiniciado. Como resultado, o jogo será reiniciado após cinco segundos (essa verificação é feita em OpenGLWindow::update). Enquanto isso, paintUI exibirá o texto de vitória.

bullets.hpp

A definição da classe Bullets ficará como a seguir:

#ifndef BULLETS_HPP_
#define BULLETS_HPP_

#include <list>

#include "abcg.hpp"
#include "gamedata.hpp"
#include "ship.hpp"

class OpenGLWindow;

class Bullets {
 public:
  void initializeGL(GLuint program);
  void paintGL();
  void terminateGL();

  void update(Ship &ship, const GameData &gameData, float deltaTime);

 private:
  friend OpenGLWindow;

  GLuint m_program{};
  GLint m_colorLoc{};
  GLint m_rotationLoc{};
  GLint m_translationLoc{};
  GLint m_scaleLoc{};

  GLuint m_vao{};
  GLuint m_vbo{};

  struct Bullet {
    bool m_dead{};
    glm::vec2 m_translation{glm::vec2(0)};
    glm::vec2 m_velocity{glm::vec2(0)};
  };

  float m_scale{0.015f};

  std::list<Bullet> m_bullets;
};

#endif

Nas linhas 32 a 36 é definida a estrutura Bullet. Observe que o VAO e VBO não está em Bullet, mas em Bullets, pois todos os tiros utilizarão o mesmo VBO.

Na linha 38 é definido o fator de escala de cada tiro, e na linha 40 é definida a lista de tiros atualmente na cena. O número de elementos da lista será alterado de acordo com a quantidade de tiros visíveis.

bullets.cpp

O arquivo começa com a definição de Bullets::initializeGL:

#include "bullets.hpp"

#include <cppitertools/itertools.hpp>
#include <glm/gtx/rotate_vector.hpp>

void Bullets::initializeGL(GLuint program) {
  terminateGL();

  m_program = program;
  m_colorLoc = abcg::glGetUniformLocation(m_program, "color");
  m_rotationLoc = abcg::glGetUniformLocation(m_program, "rotation");
  m_scaleLoc = abcg::glGetUniformLocation(m_program, "scale");
  m_translationLoc = abcg::glGetUniformLocation(m_program, "translation");

  m_bullets.clear();

  // Create regular polygon
  const auto sides{10};

  std::vector<glm::vec2> positions(0);
  positions.emplace_back(0, 0);
  const auto step{M_PI * 2 / sides};
  for (const auto angle : iter::range(0.0, M_PI * 2, step)) {
    positions.emplace_back(std::cos(angle), std::sin(angle));
  }
  positions.push_back(positions.at(1));

  // Generate VBO of positions
  abcg::glGenBuffers(1, &m_vbo);
  abcg::glBindBuffer(GL_ARRAY_BUFFER, m_vbo);
  abcg::glBufferData(GL_ARRAY_BUFFER, positions.size() * sizeof(glm::vec2),
                     positions.data(), GL_STATIC_DRAW);
  abcg::glBindBuffer(GL_ARRAY_BUFFER, 0);

  // Get location of attributes in the program
  const GLint positionAttribute{
      abcg::glGetAttribLocation(m_program, "inPosition")};

  // Create VAO
  abcg::glGenVertexArrays(1, &m_vao);

  // Bind vertex attributes to current VAO
  abcg::glBindVertexArray(m_vao);

  abcg::glEnableVertexAttribArray(positionAttribute);
  abcg::glBindBuffer(GL_ARRAY_BUFFER, m_vbo);
  abcg::glVertexAttribPointer(positionAttribute, 2, GL_FLOAT, GL_FALSE, 0,
                              nullptr);
  abcg::glBindBuffer(GL_ARRAY_BUFFER, 0);

  // End of binding to current VAO
  abcg::glBindVertexArray(0);
}

A lista de tiros é inicializada como vazia na linha 15. O restante do código é para criar o VBO que será compartilhado por todos os tiros. O VBO contém vértices de um polígono regular de 10 lados e usa o mesmo código que utilizamos no projeto regularpolygons.

A definição de Bullets::paintGL ficará como a seguir:

void Bullets::paintGL() {
  abcg::glUseProgram(m_program);

  abcg::glBindVertexArray(m_vao);
  abcg::glUniform4f(m_colorLoc, 1, 1, 1, 1);
  abcg::glUniform1f(m_rotationLoc, 0);
  abcg::glUniform1f(m_scaleLoc, m_scale);

  for (const auto &bullet : m_bullets) {
    abcg::glUniform2f(m_translationLoc, bullet.m_translation.x,
                      bullet.m_translation.y);

    abcg::glDrawArrays(GL_TRIANGLE_FAN, 0, 12);
  }

  abcg::glBindVertexArray(0);

  abcg::glUseProgram(0);
}

Todos os tiros têm a mesma cor (linha 59), ângulo de rotação (linha 60) e fator de escala (linha 61). A lista de tiros é iterada no laço das linhas 63 a 68. Cada tiro é renderizado como um GL_TRIANGLE_FAN.

Em Bullets::terminateGL é liberado o VBO e VAO:

void Bullets::terminateGL() {
  abcg::glDeleteBuffers(1, &m_vbo);
  abcg::glDeleteVertexArrays(1, &m_vao);
}

Vamos agora à definição de Bullets::update:

void Bullets::update(Ship &ship, const GameData &gameData, float deltaTime) {
  // Create a pair of bullets
  if (gameData.m_input[static_cast<size_t>(Input::Fire)] &&
      gameData.m_state == State::Playing) {
    // At least 250 ms must have passed since the last bullets
    if (ship.m_bulletCoolDownTimer.elapsed() > 250.0 / 1000.0) {
      ship.m_bulletCoolDownTimer.restart();

      // Bullets are shot in the direction of the ship's forward vector
      glm::vec2 forward{glm::rotate(glm::vec2{0.0f, 1.0f}, ship.m_rotation)};
      glm::vec2 right{glm::rotate(glm::vec2{1.0f, 0.0f}, ship.m_rotation)};
      const auto cannonOffset{(11.0f / 15.5f) * ship.m_scale};
      const auto bulletSpeed{2.0f};

      Bullet bullet{.m_dead = false,
                    .m_translation = ship.m_translation + right * cannonOffset,
                    .m_velocity = ship.m_velocity + forward * bulletSpeed};
      m_bullets.push_back(bullet);

      bullet.m_translation = ship.m_translation - right * cannonOffset;
      m_bullets.push_back(bullet);

      // Moves ship in the opposite direction
      ship.m_velocity -= forward * 0.1f;
    }
  }

  for (auto &bullet : m_bullets) {
    bullet.m_translation -= ship.m_velocity * deltaTime;
    bullet.m_translation += bullet.m_velocity * deltaTime;

    // Kill bullet if it goes off screen
    if (bullet.m_translation.x < -1.1f) bullet.m_dead = true;
    if (bullet.m_translation.x > +1.1f) bullet.m_dead = true;
    if (bullet.m_translation.y < -1.1f) bullet.m_dead = true;
    if (bullet.m_translation.y > +1.1f) bullet.m_dead = true;
  }

  // Remove dead bullets
  m_bullets.remove_if([](const Bullet &p) { return p.m_dead; });
}

Um par de tiros é criado a cada disparo. O temporizador m_bulletCoolDownTimer é utilizado para fazer com que os disparos ocorram em intervalos de no mínimo 250 milissegundos.

Observe, na linha 103, que subtraímos da velocidade da nave o vetor de direção dos tiros. Isso produz um efeito de recuo da nave. Quanto mais tiros são disparados, mais a nave será deslocada para trás.

Nas linhas 107 a 116 são atualizadas as coordenadas de translação de cada tiro.

Nas linhas 108 e 109, os tiros são atualizados de acordo com a velocidade da nave e a velocidade do próprio tiro.

Nas linhas 111 a 115, verifica-se se o tiro saiu da tela. Se sim, o flag m_dead torna-se true. A comparação é feita com -1.1f/+1.1f no lugar de -1.0f/+1.0f para ter certeza que todo o polígono do tiro saiu da tela.

Na linha 119, todos os tiros com m_dead == true são removidos da lista.

Isso é tudo! Eis o jogo completo (link original):

O código do projeto completo pode ser baixado deste link.


  1. As estrelas das camadas superiores se moverão mais rapidamente do que as estrelas das camadas inferiores, dando a sensação de profundidade do espaço.↩︎

  2. As declarações antecipadas são utilizadas no lugar do #include para evitar inclusões recursivas. Por exemplo, openglwindow.hpp inclui ship.hpp, então ship.hpp não pode incluir openglwindow.hpp.↩︎

  3. O vetor é \([0\; 1]\) pois a nave está alinhada ao eixo \(y\) positivo em sua orientação original.↩︎