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.- Primeiro, precisamos encontrar suas coordenadas em relação ao centro do mundo em que o objeto está localizado.
- Depois, conhecendo as coordenadas e a localização da câmera, encontre a posição do vértice na base da câmera.
- 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()
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():
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íveisEditor de máquina de estadoConclusã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