5.2 Asteroids

O cenário do jogo Asteroids será composto 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 não desenhar os objetos que estão totalmente 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 potencialmente 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 Window como fizemos nos projetos anteriores.

Organização das classes

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

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

  • StarLayers: classe que gerenciará as camadas de estrelas usadas para fazer o efeito de paralaxe de fundo26. StarLayers conterá um arranjo de objetos do tipo StarLayer, sendo que cada StarLayer definirá o VBO de pontos de uma camada de estrelas.

  • Bullets: classe que gerencia os tiros. A classe terá uma lista de instâncias de uma estrutura Bullet, sendo que cada Bullet representará as propriedades de um tiro (translação, velocidade, etc). Todos os tiros compartilharão um mesmo VBO definido como membro de Bullets.

  • Asteroids: classe que gerenciará os asteroides. Asteroids conterá uma lista de instâncias de uma estrutura Asteroid, sendo que cada Asteroid definirá o VBO e as propriedades de um asteroide.

As classes Ship, StarLayers, Bullets e Asteroids terão funções públicas create, paint e destroy que serão chamadas respectivamente nas funções onCreate, onPaint e onDestroy de Window.

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

Organização dos 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
│   window.hpp
│   window.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 terá os seguintes arquivos de recursos utilizados no jogo:

  • Inconsolata-Medium.ttf: fonte Inconsolata utilizada na mensagem “Game Over” e “You Win”. É a mesma fonte que utilizados no projeto tictactoe (seção 2.4).
  • stars.vert e stars.frag: código-fonte do vertex shader e fragment shader utilizados para renderizar as estrelas.
  • objects.vert e objects.frag: 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, como fizemos até agora, mas o projeto fica mais organizado desta nova forma.

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 sã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 window.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 eles 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 "window.hpp"

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

    Window window;
    window.setOpenGLSettings({.samples = 4});
    window.setWindowSettings({
        .width = 600,
        .height = 600,
        .showFPS = false,
        .showFullscreenButton = false,
        .title = "Asteroids",
    });

    app.run(window);
  } catch (std::exception const &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: a aplicação está em modo de jogo, com a nave respondendo aos comandos do jogador;
    • State::GameOver: o jogador perdeu. Nesse caso a nave não é exibida e não responde aos comandos do jogador;
    • State::Win: o jogador ganhou. A nave também não é exibida neste estado.
  • m_input é uma máscara de 5 bits setados em resposta a eventos dos dispositivos de entrada. Por exemplo, o bit 0 corresponde a Input::Right e está setado enquanto o usuário pressiona a seta para a direita ou a tecla D. Esse estado é atualizado pela função membro Window::onEvent que veremos adiante.

A classe Window manterá um objeto GameData que será compartilhado 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 300 es

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 (linha 18).

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 estética 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 300 es

precision mediump float;

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 300 es

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 300 es

precision mediump float;

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. O ponto é na verdade 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 window.hpp

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

#ifndef WINDOW_HPP_
#define WINDOW_HPP_

#include <random>

#include "abcgOpenGL.hpp"

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

class Window : public abcg::OpenGLWindow {
protected:
  void onEvent(SDL_Event const &event) override;
  void onCreate() override;
  void onUpdate() override;
  void onPaint() override;
  void onPaintUI() override;
  void onResize(glm::ivec2 const &size) override;
  void onDestroy() override;

private:
  glm::ivec2 m_viewportSize{};

  GLuint m_starsProgram{};
  GLuint m_objectsProgram{};

  GameData m_gameData;

  Ship m_ship;
  StarLayers m_starLayers;

  abcg::Timer m_restartWaitTimer;

  ImFont *m_font{};

  std::default_random_engine m_randomEngine;

  void restart();
};

#endif

Atualizando window.cpp

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

  • Em Window::onCreate, inclua o seguinte código para compilar os novos shaders:

    // Create program to render the stars
    m_starsProgram =
        abcg::createOpenGLProgram({{.source = assetsPath + "stars.vert",
                                    .stage = abcg::ShaderStage::Vertex},
                                   {.source = assetsPath + "stars.frag",
                                    .stage = abcg::ShaderStage::Fragment}});
  • Em Window::restart, inclua a chamada a StarLayers::create junto com a chamada a Ship::create feita anteriormente:

    m_starLayers.create(m_starsProgram, 25);
    m_ship.create(m_objectsProgram);
  • Em Window::onUpdate, chame a função StarLayers::update depois de Ship::update, assim:

    m_ship.update(m_gameData, deltaTime);
    m_starLayers.update(m_ship, deltaTime);
  • Em Window::onPaint, chame StarLayers::paint antes de Ship::paint, assim:

    m_starLayers.paint();
    m_ship.paint(m_gameData);
  • Por fim, modifique Window::onDestroy da seguinte forma:

    void Window::onDestroy() {
      abcg::glDeleteProgram(m_starsProgram);
      abcg::glDeleteProgram(m_objectsProgram);
    
      m_ship.destroy();
      m_starLayers.destroy();
    }

starlayers.hpp

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

#ifndef STARLAYERS_HPP_
#define STARLAYERS_HPP_

#include <array>
#include <random>

#include "abcgOpenGL.hpp"

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

class StarLayers {
public:
  void create(GLuint program, int quantity);
  void paint();
  void destroy();
  void update(const Ship &ship, float deltaTime);

private:
  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{};
  };

  std::array<StarLayer, 5> m_starLayers;

  std::default_random_engine m_randomEngine;
};

#endif

Nas linhas 24 a 31 é definida a estrutura StarLayer. Ela contém o VBO e VAO dos pontos que formam uma “camada” de estrelas. Além disso contém o tamanho (m_pointSize) e quantidade (m_quantity) dos 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 33 é definido um arranjo de cinco objetos StarLayer, pois renderizaremos cinco camadas sobrepostas de estrelas.

starlayers.cpp

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

#include "starlayers.hpp"

void StarLayers::create(GLuint program, int quantity) {
  destroy();

  // Initialize pseudorandom number generator and distributions
  m_randomEngine.seed(
      std::chrono::steady_clock::now().time_since_epoch().count());
  std::uniform_real_distribution distPos(-1.0f, 1.0f);
  std::uniform_real_distribution distIntensity(0.5f, 1.0f);
  auto &re{m_randomEngine}; // Shortcut

  m_program = program;

  // Get location of uniforms in the program
  m_pointSizeLoc = abcg::glGetUniformLocation(m_program, "pointSize");
  m_translationLoc = abcg::glGetUniformLocation(m_program, "translation");

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

  for (auto &&[index, layer] : iter::enumerate(m_starLayers)) {
    // Create data for the stars of this layer
    layer.m_pointSize = 10.0f / (1.0f + index);
    layer.m_quantity = quantity * (gsl::narrow<int>(index) + 1);
    layer.m_translation = {};

    std::vector<glm::vec3> data;
    for ([[maybe_unused]] auto _ : iter::range(0, layer.m_quantity)) {
      data.emplace_back(distPos(re), distPos(re), 0);
      data.push_back(glm::vec3(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);

    // 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 24 itera sobre cada elemento de m_starLayers. A expressão na linha 26 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 27, a quantidade de pontos é dobrada a cada camada.

Na linha 30 é criado um arranjo data com dados dos pontos da camada. Os dados ficarão intercalados no formato \[[\underline{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\) são escolhidas de forma aleatória dentro do intervalo \([-1,1)\). A cor é 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 39.

Observe nas linhas 49 a 57 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 56, o deslocamento no início do VBO é sizeof(glm::vec3) (isto é, apenas um vec3). A conversão de tipo é necessária porque o parâmetro de deslocamento é do tipo void const * ao invés de um inteiro (é assim por razões históricas).

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

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

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

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

    for (auto const i : {-2, 0, 2}) {
      for (auto const 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 68, 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 74 e 75 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 86, 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::destroy são liberados os VBOs e VAOs de todas as instâncias de StarLayer:

void StarLayers::destroy() {
  for (auto &layer : m_starLayers) {
    abcg::glDeleteBuffers(1, &layer.m_VBO);
    abcg::glDeleteVertexArrays(1, &layer.m_VAO);
  }
}

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

void StarLayers::update(const Ship &ship, float deltaTime) {
  for (auto &&[index, layer] : iter::enumerate(m_starLayers)) {
    auto const 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;
  }
}

A translação (m_translation) de cada camada é atualizada na linha 101 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. 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 104 a 111 há uma série de condicionais que testam se os pontos saíram dos limites da região visível do NDC. Se saíram, são deslocados para o lado oposto.

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 window.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 window.cpp

  • Em Window::restart, chame create de m_asteroids junto com a chamada a create dos objetos anteriores:

    m_starLayers.create(m_starsProgram, 25);
    m_ship.create(m_objectsProgram);
    m_asteroids.create(m_objectsProgram, 3);
  • Em Window::onUpdate, chame update de m_asteroids após update de m_ship:

    m_ship.update(m_gameData, deltaTime);
    m_starLayers.update(m_ship, deltaTime);
    m_asteroids.update(m_ship, deltaTime);
  • Em Window::onPaint, chame paint de m_asteroids logo após paint de m_starLayers:

    m_starLayers.paint();
    m_asteroids.paint();
    m_ship.paint(m_gameData);
  • Em Window::onDestroy, chame destroy de m_asteroids junto com a chamada a destroy dos outros objetos:

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

A ordem em que a função paint de cada objeto é chamada é importante porque o objeto renderizado por último será desenhado sobre os anteriores que já estão 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: os elementos mais ao fundo são desenhados antes dos elementos mais à frente.

asteroids.hpp

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

#ifndef ASTEROIDS_HPP_
#define ASTEROIDS_HPP_

#include <list>
#include <random>

#include "abcgOpenGL.hpp"

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

class Asteroids {
public:
  void create(GLuint program, int quantity);
  void paint();
  void destroy();
  void update(const Ship &ship, float deltaTime);

  struct Asteroid {
    GLuint m_VAO{};
    GLuint m_VBO{};

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

  std::list<Asteroid> m_asteroids;

  Asteroid makeAsteroid(glm::vec2 translation = {}, float scale = 0.25f);

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

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

#endif

Entre as linhas 19 a 31 é definida a estrutura Asteroid. Cada asteroide tem seu próprio VAO e VBO. Além disso, Asteroid 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 33 é definida uma lista de objetos 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 fazendo com asteroides menores sejam inseridos na lista.

A função makeAsteroid declarada na linha 35 será utilizada para criar um novo asteroide para ser inserido na lista m_asteroids. Os parâmetros translation e scale permitirão configurar a posição e o fator de escala do novo asteroide.

asteroids.cpp

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

#include "asteroids.hpp"

#include <glm/gtx/fast_trigonometry.hpp>

void Asteroids::create(GLuint program, int quantity) {
  destroy();

  m_randomEngine.seed(
      std::chrono::steady_clock::now().time_since_epoch().count());

  m_program = program;

  // Get location of uniforms in the 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 = makeAsteroid();

    // 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 da linha 23 e o conteúdo de cada asteroide é substituído por Asteroids::makeAsteroid. No laço das linhas 27 a 30 é escolhida uma posição aleatória para o asteroide, mas uma posição longe o suficiente da nave: não queremos que o jogo comece com o asteroide colidindo com a nave!

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

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

  for (auto const &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::destroy são liberados os VBOs e VAOs dos asteroides:

void Asteroids::destroy() {
  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 80 fazem com que as coordenadas de m_translation permaneçam no intervalo circular de -1 a 1.

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

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

  auto &re{m_randomEngine}; // Shortcut

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

  // Get a random color (actually, a grayscale)
  std::uniform_real_distribution randomIntensity(0.5f, 1.0f);
  asteroid.m_color = glm::vec4(randomIntensity(re));

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

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

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

  // Create geometry data
  std::vector<glm::vec2> positions{{0, 0}};
  auto const step{M_PI * 2 / asteroid.m_polygonSides};
  std::uniform_real_distribution randomRadius(0.8f, 1.0f);
  for (auto const angle : iter::range(0.0, M_PI * 2, step)) {
    auto const 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
  auto const 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 105 é escolhida uma velocidade angular aleatória no intervalo \([-1, 1)\). Essa velocidade será interpretada como sendo em radianos por segundo.

Na linha 109 é 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

Até agora o jogo ainda não tem detecção de colisão com os asteroides. Faremos isso a seguir.

Atualizando window.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 adicionais de Window:

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 window.cpp

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

    m_starLayers.create(m_starsProgram, 25);
    m_ship.create(m_objectsProgram);
    m_asteroids.create(m_objectsProgram, 3);
    m_bullets.create(m_objectsProgram);
  • Em Window::onUpdate, 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);

    Após esses updates, inclua a seguinte condicional que chama as funções de detecção de colisão e verificação da condição de vitória se o jogo está no estado State::Playing:

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

    m_starLayers.paint();
    m_asteroids.paint();
    m_bullets.paint();
    m_ship.paint(m_gameData);
  • Em Window::onDestroy, chame destroy de m_bullets junto com destroy dos outros objetos:

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

Vamos agora definir Window::checkCollisions como a seguir:

void Window::checkCollisions() {
  // Check collision between ship and asteroids
  for (auto const &asteroid : m_asteroids.m_asteroids) {
    auto const asteroidTranslation{asteroid.m_translation};
    auto const 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 (auto const i : {-2, 0, 2}) {
        for (auto const j : {-2, 0, 2}) {
          auto const asteroidTranslation{asteroid.m_translation +
                                         glm::vec2(i, j)};
          auto const 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 (auto const &asteroid : m_asteroids.m_asteroids) {
      if (asteroid.m_hit && asteroid.m_scale > 0.10f) {
        std::uniform_real_distribution randomDist{-1.0f, 1.0f};
        std::generate_n(std::back_inserter(m_asteroids.m_asteroids), 3, [&]() {
          glm::vec2 const offset{randomDist(m_randomEngine),
                                 randomDist(m_randomEngine)};
          auto const newScale{asteroid.m_scale * 0.5f};
          return m_asteroids.makeAsteroid(
              asteroid.m_translation + offset * newScale, newScale);
        });
      }
    }

    m_asteroids.m_asteroids.remove_if([](auto const &a) { return a.m_hit; });
  }
}

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

  // Check collision between ship and asteroids
  for (auto const &asteroid : m_asteroids.m_asteroids) {
    auto const asteroidTranslation{asteroid.m_translation};
    auto const 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 180, 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.

O laço for da linha 187 itera sobre os tiros. Dentro desse laço há outro (linha 191) que itera sobre os asteroides para verificar se há colisão entre cada tiro 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 (auto const i : {-2, 0, 2}) {
        for (auto const j : {-2, 0, 2}) {
          auto const asteroidTranslation{asteroid.m_translation +
                                         glm::vec2(i, j)};
          auto const 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 verificação da 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 207 a 219:

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

Neste 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. std::generate é uma função da biblioteca padrão do C++. Ela chama um número de vezes (nesse caso 3 vezes) a expressão lambda passada como último argumento. O valor de retorno da expressão lambda é inserido no fim de m_asteroids.m_asteroids usando a função auxiliar std::back_inserter.

Na linha 221, os asteroides que estavam com m_hit == true são removidos da lista usando std::remove_if:

    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 Window::checkWinCondition, que ficará como a seguir:

void Window::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 Window::onUpdate). Enquanto isso, o texto de vitória será exibido em Window::onPaintUI.

bullets.hpp

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

#ifndef BULLETS_HPP_
#define BULLETS_HPP_

#include <list>

#include "abcgOpenGL.hpp"

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

class OpenGLWindow;

class Bullets {
public:
  void create(GLuint program);
  void paint();
  void destroy();
  void update(Ship &ship, const GameData &gameData, float deltaTime);

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

  std::list<Bullet> m_bullets;

  float m_scale{0.015f};

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

  GLuint m_VAO{};
  GLuint m_VBO{};
};

#endif

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

Na linha 26 é definida a lista de tiros atualmente na cena. O número de elementos desta lista será alterado de acordo com a quantidade de tiros visíveis.

Na linha 28 é definido o fator de escala comum a todos os tiros.

bullets.cpp

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

#include "bullets.hpp"

#include <glm/gtx/rotate_vector.hpp>

void Bullets::create(GLuint program) {
  destroy();

  m_program = program;

  // Get location of uniforms in the 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");

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

  m_bullets.clear();

  // Create geometry data
  auto const sides{10};
  std::vector<glm::vec2> positions{{0, 0}};
  auto const step{M_PI * 2 / sides};
  for (auto const 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);

  // 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 20. 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::paint ficará como a seguir:

void Bullets::paint() {
  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 (auto const &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 58), ângulo de rotação (linha 59) e fator de escala (linha 60). A lista de tiros é iterada no laço das linhas 62 a 67. Cada tiro é renderizado como um GL_TRIANGLE_FAN.

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

void Bullets::destroy() {
  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[gsl::narrow<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
      auto const forward{glm::rotate(glm::vec2{0.0f, 1.0f}, ship.m_rotation)};
      auto const right{glm::rotate(glm::vec2{1.0f, 0.0f}, ship.m_rotation)};
      auto const cannonOffset{(11.0f / 15.5f) * ship.m_scale};
      auto const 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([](auto const &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 102, 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 106 a 119 são atualizadas as coordenadas de translação de cada tiro.

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

Nas linhas 110 a 118, verificamos 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 (e não só seu centro) saiu da tela.

Na linha 122, 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. O vetor é \((0, 1)\) pois a nave está alinhada ao eixo \(y\) positivo em sua orientação original.↩︎