Escrevendo um mecanismo de jogo em seu primeiro ano: fácil! (Quase)

Olá! Meu nome é Gleb Maryin, estou no meu primeiro ano de graduação em "Matemática Aplicada e Ciência da Computação" no HSE de São Petersburgo. No segundo semestre, todos os calouros do nosso programa fazem projetos de equipe em C ++. Meus colegas de equipe e eu decidimos escrever um mecanismo de jogo. 

Leia o que temos sob o gato.


Somos três na equipe: eu, Alexei Luchinin e Ilya Onofriychuk. Nenhum de nós é especialista em desenvolvimento de jogos, muito menos na criação de mecanismos de jogos. Este é o primeiro grande projeto para nós: antes dele, realizamos apenas trabalhos de casa e trabalhos de laboratório, por isso é improvável que os profissionais da área de computação gráfica encontrem novas informações aqui. Ficaremos felizes se nossas idéias ajudarem aqueles que também desejam criar seu próprio mecanismo. Mas esse tópico é complexo e multifacetado, e o artigo de forma alguma afirma ser uma literatura especializada completa.

Todo mundo que estiver interessado em aprender sobre nossa implementação - aproveite a leitura!

Artes gráficas


Primeira janela, mouse e teclado


Para criar janelas, manipular a entrada do mouse e do teclado, escolhemos a biblioteca SDL2. Foi uma escolha aleatória, mas até agora não nos arrependemos. 

Era importante, no primeiro estágio, escrever um invólucro conveniente sobre a biblioteca para que você pudesse criar uma janela com algumas linhas, manipulá-lo como mover o cursor e entrar no modo de tela cheia e manipular eventos: pressionamentos de tecla, movimentos do cursor. A tarefa não foi difícil: criamos rapidamente um programa que pode fechar e abrir uma janela e, quando você clica em RMB, exibe "Olá, Mundo!". 

Então o ciclo principal do jogo apareceu:

Event ev;
bool running = true;
while (running):
	ev = pullEvent();
	for handler in handlers[ev.type]:
		handler.handleEvent(ev);

Cada manipulador de eventos anexado - handlerspor exemplo handlers[QUIT] = {QuitHandler()}. Sua tarefa é manipular o evento correspondente. QuitHandlerNo exemplo, ele será exposto running = false, interrompendo o jogo.

Olá Mundo


Para desenhar no motor que usamos OpenGL. O primeiro Hello World, como penso, em muitos projetos, era um quadrado branco sobre fundo preto: 

glBegin(GL_QUADS);
glVertex2f(-1.0f, 1.0f);
glVertex2f(1.0f, 1.0f);
glVertex2f(1.0f, -1.0f);
glVertex2f(-1.0f, -1.0f);
glEnd();


Em seguida, aprendemos a desenhar um polígono bidimensional e executamos as figuras em uma classe separada GraphicalObject2d, que pode girar glRotate, mover glTranslatee esticar glScale. Definimos a cor em quatro canais usando glColor4f(r, g, b, a).

Com essa funcionalidade, você já pode fazer uma bela fonte de quadrados. Crie uma classe ParticleSystemque tenha uma matriz de objetos. A cada iteração do loop principal, o sistema de partículas atualiza os quadrados antigos e coleta alguns novos iniciados em uma direção aleatória:



Câmera


O próximo passo foi escrever uma câmera que pudesse se mover e olhar em direções diferentes. Para entender como resolver esse problema, precisávamos de conhecimento da álgebra linear. Se isso não for muito interessante para você, você pode pular a seção, ver o gif e ler .

Queremos desenhar um vértice nas coordenadas da tela, conhecendo suas coordenadas em relação ao centro do objeto ao qual ele pertence.

  1. Primeiro, precisamos encontrar suas coordenadas em relação ao centro do mundo em que o objeto está localizado.
  2. Depois, conhecendo as coordenadas e a localização da câmera, encontre a posição do vértice na base da câmera.
  3. Em seguida, projete o vértice no plano da tela. 

Como você pode ver, existem três estágios. Eles correspondem a multiplicações por três matrizes. Chamamos essas matrizes Model, Viewe Projection.

Vamos começar obtendo as coordenadas do objeto na base do mundo. Três transformações podem ser feitas com um objeto: dimensionar, girar e mover. Todas essas operações são especificadas multiplicando o vetor original (coordenadas na base do objeto) pelas matrizes correspondentes. Então a matriz Modelficará assim: 

Model = Translate * Scale * Rotate. 

Além disso, conhecendo a posição da câmera, queremos determinar as coordenadas em sua base: multiplique as coordenadas obtidas anteriormente pela matriz View. No C ++, isso é convenientemente calculado usando a função:


glm::mat4 View = glm::lookAt(cameraPosition, objectPosition, up);

Literalmente: olhe objectPositionde uma posição cameraPositione a direção para cima é "para cima". Por que essa direção é necessária? Imagine fotografar um bule de chá. Você aponta a câmera para ele e coloca a chaleira na moldura. Nesse ponto, você pode dizer exatamente onde o quadro está no topo (provavelmente onde a chaleira tem uma tampa). O programa não pode descobrir como organizar o quadro, e é por isso que o vetor "up" deve ser especificado.

Temos as coordenadas na base da câmera, resta projetar as coordenadas obtidas no plano da câmera. A matriz está envolvida nisso Projection, o que cria o efeito de reduzir o objeto quando ele é removido de nós.

Para obter as coordenadas do vértice na tela, você precisa multiplicar o vetor pela matriz pelo menos cinco vezes. Todas as matrizes têm um tamanho de 4 por 4, então você deve executar algumas operações de multiplicação. Não queremos carregar núcleos de processador com muitas tarefas simples. Para isso, uma placa de vídeo com os recursos necessários é melhor. Então, você precisa escrever um shader: uma pequena instrução para uma placa de vídeo. O OpenGL possui uma linguagem de shader GLSL especial, semelhante ao C, que nos ajudará a fazer isso. Não vamos entrar nos detalhes de escrever um shader, é melhor finalmente ver o que aconteceu:


Explicação: Existem dez quadrados a uma curta distância um do outro. No lado direito deles está um jogador que gira e move a câmera. 

Física


O que é um jogo sem física? Para lidar com a interação física, decidimos usar a biblioteca Box2d e criamos uma classe WorldObject2dque é herdada GraphicalObject2d. Infelizmente, o Box2d não funcionou imediatamente, então a corajosa Ilya escreveu um wrapper para o b2Body e todas as conexões físicas que estão nesta biblioteca.


Até aquele momento, pensamos em tornar os gráficos no mecanismo absolutamente bidimensionais e, para a iluminação, se decidirmos adicioná-lo, use a técnica de raycasting. Mas tínhamos à mão uma câmera maravilhosa que pode exibir objetos nas três dimensões. Portanto, adicionamos espessura a todos os objetos bidimensionais - por que não? Além disso, no futuro, isso permitirá que você faça uma iluminação bastante bonita que deixará sombras de objetos grossos.

Apareceu iluminação entre os casos. Para criá-lo, foi necessário escrever as instruções apropriadas para desenhar cada pixel - um shader de fragmento.



Texturas


Usamos a biblioteca DevIL para fazer upload de imagens. Cada GraphicalObject2dum deles se encaixou em uma instância da classe GraphicalPolygon- a parte da frente do objeto - e a GraphicalEdgeparte lateral. Em cada um você pode esticar sua textura. Primeiro resultado:


Tudo o que é necessário nos gráficos está pronto: desenho, uma fonte de luz e textura. Gráficos - é isso por enquanto.

Máquina de estado, definindo comportamentos de objeto


Todo objeto, seja ele qual for - um estado na máquina de estado, gráfico ou físico - deve estar "correndo", ou seja, cada iteração do loop do jogo é atualizada.

Os objetos que podem ser atualizados são herdados da classe Behavior que criamos. Possui funções onStart, onActive, onStopque permitem substituir o comportamento do herdeiro na inicialização, durante a vida e no final de sua atividade. Agora precisamos criar um objeto supremo Activityque chame essas funções de todos os objetos. A função de loop que faz isso é a seguinte:

void loop():
    onAwake();
    awake = true;
    while (awake):
        onStart();
        running = true
        while (running):
            onActive();
        onStop();
    onDestroy();

Por enquanto running == true, alguém pode chamar uma função pause()que faz isso running = false. Se alguém ligar kill(), então awake, runningvolte para falsee atividade para uma parada completa.

Problema: queremos pausar um grupo de objetos, por exemplo, um sistema de partículas e partículas dentro dele. No estado atual, você precisa chamar manualmente onPausepara cada objeto, o que não é muito conveniente.

Solução: todos Behaviorterão uma matriz subBehaviorsque ele atualizará, ou seja:

void onStart():
	onStart() 		//     
	for sb in subBehaviors:
		sb.onStart()	//       Behavior
void onActive():
	onActive()
	for sb in subBehaviors:
		sb.onActive()

E assim por diante, para cada função.

Mas nem todo comportamento pode ser definido dessa maneira. Por exemplo, se um inimigo está andando em uma plataforma - inimigo, então ele provavelmente tem estados diferentes: ele está de pé - idle_stay, ele está andando em uma plataforma sem nos notar - idle_walke a qualquer momento ele pode nos notar e entrar em um estado de ataque - attack. Também quero definir convenientemente as condições para a transição entre estados, por exemplo:

bool isTransitionActivated(): 		//  idle_walk->attack
	return canSee(enemy);

O padrão desejado é uma máquina de estados. Também a fizemos herdeira Behavior, já que em cada escala é necessário verificar se chegou a hora de mudar de estado. Isso é útil não apenas para objetos no jogo. Por exemplo, Leveleste é um estado Level Switcher, e as transições dentro da máquina do controlador são condições para alterar os níveis no jogo.

O estado tem três estágios: começou, está correndo, parou. Você pode adicionar algumas ações a cada estágio, por exemplo, anexar uma textura a um objeto, aplicar um impulso a ele, definir a velocidade e assim por diante.

Conservação


Ao criar um nível no editor, desejo poder salvá-lo, e o próprio jogo deve poder carregar o nível a partir dos dados salvos. Portanto, todos os objetos que precisam ser salvos são herdados da classe NamedStoredObject. Ele armazena uma string com o nome, nome da classe e tem uma função dump()que despeja dados sobre um objeto em uma string.  

Para salvar, resta simplesmente substituir dump()por cada objeto. Loading é um construtor a partir de uma string que contém todas as informações sobre o objeto. O download é concluído quando esse construtor é feito para cada objeto. 

De fato, o jogo e o editor são quase da mesma classe, apenas no jogo o nível é carregado no modo de leitura e no editor no modo de gravação. O mecanismo usa a biblioteca rapidjson para escrever e ler objetos do json.

GUI


Em algum momento, surgiu a questão diante de nós: escrevam os gráficos, a máquina de estado e todo o resto. Como um usuário pode escrever um jogo usando isso? 

Na versão original, ele teria que herdar Game2de substituir onActivee criar objetos nos campos da classe. Mas durante a criação, ele não pode ver o que está criando e também precisa compilar seu programa e vincular-se à nossa biblioteca. Horror! Haveria vantagens - poderia-se perguntar comportamentos tão complexos que se poderia imaginar: por exemplo, mover o terreno tanto quanto a vida do jogador e fazê-lo desde que Urano esteja na constelação de Touro e o euro não exceda 40 rublos. No entanto, ainda decidimos fazer uma interface gráfica.

Na interface gráfica, o número de ações que podem ser executadas com um objeto será limitado: percorra o slide de animação, aplique força, defina uma certa velocidade e assim por diante. A mesma situação com transições na máquina de estado. Em mecanismos grandes, o problema de um número limitado de ações é resolvido vinculando o programa atual a outro - por exemplo, Unity e Godot usam vinculação com C #. Já a partir deste script, você pode fazer qualquer coisa: e ver em qual constelação Urano e qual é a taxa de câmbio atual do euro. No momento, não temos essa funcionalidade, mas nossos planos incluem conectar o mecanismo ao Python 3.

Para implementar a interface gráfica, decidimos usar o Dear ImGui, porque é muito pequeno (comparado ao conhecido Qt) e escrever sobre ele é muito simples. ImGui - o paradigma da criação de uma interface gráfica. Nele, toda iteração do loop principal, todos os widgets e janelas são redesenhados apenas se necessário. Por um lado, isso reduz a quantidade de memória consumida, mas, por outro lado, provavelmente leva mais de uma execução da função complexa de criar e salvar as informações necessárias para o desenho subsequente. Resta apenas implementar as interfaces para criação e edição.

Veja como é a GUI no momento do lançamento do artigo:


Editor de níveis


Editor de máquina de estado

Conclusão


Criamos apenas a base sobre a qual você pode pendurar algo mais interessante. Em outras palavras, há espaço para crescimento: você pode implementar a renderização de sombra, a capacidade de criar mais de uma fonte de luz, você pode conectar o mecanismo ao interpretador Python 3 para escrever scripts para o jogo. Gostaria de refinar a interface: torná-la mais bonita, adicionar mais objetos diferentes, suporte para teclas de atalho ...

Ainda há muito trabalho, mas estamos felizes com o que temos no momento. 

Durante a criação do projeto, obtivemos muita experiência diversificada: trabalhando com gráficos, criando interfaces gráficas, trabalhando com arquivos json, wrappers de inúmeras bibliotecas C. E também a experiência de escrever o primeiro grande projeto em uma equipe. Esperamos poder contar sobre o mais interessante possível: :)

Link para o projeto gihab : github.com/Glebanister/ample

All Articles