Introdução à programação: um simples jogo de tiro em 3D do zero no fim de semana, parte 1

Este texto é destinado a quem está apenas dominando a programação. A idéia principal é mostrar passo a passo como você pode fazer o jogo de forma independente no Wolfenstein 3D . Atenção, não vou competir com Carmack, ele é um gênio e seu código é bonito. Meu objetivo é um lugar completamente diferente: uso o enorme poder computacional dos computadores modernos para que os alunos possam criar projetos engraçados em poucos dias sem se prenderem na natureza da otimização. Eu escrevo especificamente um código lento, pois é muito mais curto e fácil de entender. Carmack escreve 0x5f3759df , eu escrevo 1 / sqrt (x). Temos objetivos diferentes.

Estou convencido de que um bom programador é obtido apenas de alguém que codifica em casa por prazer, e não apenas se senta em pares na universidade. Em nossa universidade, os programadores são ensinados em uma série interminável de todos os tipos de catálogos de bibliotecas e outros tédio. Brr Meu objetivo é mostrar exemplos de projetos que são interessantes para programar. Este é um círculo vicioso: se é interessante fazer um projeto, uma pessoa gasta muito tempo nele, ganhando experiência e vê coisas ainda mais interessantes ao seu redor (tornou-se mais acessível!), E novamente imerge em um novo projeto. Isso é chamado de treinamento do projeto, em torno de lucro sólido.

A folha acabou sendo longa, então eu quebrei o texto em duas partes:


A execução do código do meu repositório é assim:


Este não é um jogo acabado, mas apenas um espaço em branco para os alunos. Um exemplo de um jogo terminado, escrito por dois calouros, veja a segunda parte .

Acontece que eu te enganei um pouco, não vou lhe dizer como fazer um jogo completo em um fim de semana. Eu fiz apenas um mecanismo 3D. Monstros não correm para mim, e o personagem principal não atira. Mas pelo menos eu escrevi esse mecanismo em um sábado, você pode verificar o histórico de confirmações. Em princípio, os domingos são suficientes para tornar algo jogável, ou seja, um fim de semana que você pode conhecer.

No momento da redação deste artigo, o repositório contém 486 linhas de código:

haqreu@daffodil:~/tinyraycaster$ cat *.cpp *.h | wc -l 486 

O projeto depende do SDL2, mas, em geral, a interface da janela e o processamento de eventos do teclado aparecem bastante tarde, à meia-noite do sábado :), quando todo o código de renderização já foi feito.

Então, eu divido todo o código em etapas, começando com o compilador C ++. Como em meus artigos anteriores sobre o cronograma ( tyts , tyts , tyts ), eu aderi à regra "one step = one commit", pois o github facilita a visualização do histórico de alterações de código.

Etapa 1: salvar a imagem no disco


Então vamos lá. Ainda estamos muito longe da interface da janela, para começar, apenas salvaremos as imagens em disco. No total, precisamos armazenar a imagem na memória do computador e salvá-la em disco em um formato que algum programa de terceiros entenda. Eu quero obter este arquivo:



Aqui está o código C ++ completo que desenha o que precisamos:

 #include <iostream> #include <fstream> #include <vector> #include <cstdint> #include <cassert> uint32_t pack_color(const uint8_t r, const uint8_t g, const uint8_t b, const uint8_t a=255) { return (a<<24) + (b<<16) + (g<<8) + r; } void unpack_color(const uint32_t &color, uint8_t &r, uint8_t &g, uint8_t &b, uint8_t &a) { r = (color >> 0) & 255; g = (color >> 8) & 255; b = (color >> 16) & 255; a = (color >> 24) & 255; } void drop_ppm_image(const std::string filename, const std::vector<uint32_t> &image, const size_t w, const size_t h) { assert(image.size() == w*h); std::ofstream ofs(filename); ofs << "P6\n" << w << " " << h << "\n255\n"; for (size_t i = 0; i < h*w; ++i) { uint8_t r, g, b, a; unpack_color(image[i], r, g, b, a); ofs << static_cast<char>(r) << static_cast<char>(g) << static_cast<char>(b); } ofs.close(); } int main() { const size_t win_w = 512; // image width const size_t win_h = 512; // image height std::vector<uint32_t> framebuffer(win_w*win_h, 255); // the image itself, initialized to red for (size_t j = 0; j<win_h; j++) { // fill the screen with color gradients for (size_t i = 0; i<win_w; i++) { uint8_t r = 255*j/float(win_h); // varies between 0 and 255 as j sweeps the vertical uint8_t g = 255*i/float(win_w); // varies between 0 and 255 as i sweeps the horizontal uint8_t b = 0; framebuffer[i+j*win_w] = pack_color(r, g, b); } } drop_ppm_image("./out.ppm", framebuffer, win_w, win_h); return 0; } 

Se você não tem um compilador em mãos, isso não importa. Se você possui uma conta no github, pode ver esse código, editá-lo e executá-lo (sic!) Em um clique diretamente no navegador.

Abrir no gitpod

Seguindo esse link, o gitpod criará uma máquina virtual para você, iniciará o VS Code e abrirá um terminal na máquina remota. No histórico de comandos do terminal (clique no console e pressione a seta para cima), já existe um conjunto completo de comandos que permitem compilar o código, executá-lo e abrir a imagem resultante.

Então, o que você precisa entender deste código. Primeiro, as cores armazenadas em um número inteiro de quatro bytes uint32_t. Cada byte é um componente de R, G, B ou A. As funções pack_color () e unpack_color () permitem acessar os componentes individuais de cada cor.

A segunda imagem bidimensional, guardo na matriz unidimensional usual. Para chegar ao pixel com coordenadas (x, y), não escrevo a imagem [x] [y], mas escrevo a imagem [x + y * width]. Se esse método de compactação de informações bidimensionais em uma matriz unidimensional for novo para você, pegue agora mesmo uma caneta e lide com ela. Para mim, pessoalmente, esse estágio nem chega ao cérebro, é processado diretamente na medula espinhal. Matrizes tridimensionais e mais dimensionais podem ser empacotadas exatamente da mesma maneira, mas não vamos ultrapassar os dois componentes.

Depois, percorro minha foto em um ciclo duplo simples, preencho-o com um gradiente e salve-o em disco no formato .ppm.



Etapa 2: desenhe um mapa de nível


Precisamos de um mapa do nosso mundo. Neste ponto, eu só quero determinar a estrutura de dados e desenhar um mapa na tela. Deve ser algo como isto:



As mudanças que você pode ver aqui . Tudo é simples aqui: codifiquei o mapa em uma matriz unidimensional de caracteres, defini a função de desenhar um retângulo e andei pelo mapa, desenhando cada célula.

Lembro que este botão iniciará o código neste estágio:

Abrir no gitpod



Etapa 3: adicionar um jogador


O que precisamos para atrair um jogador no mapa? Coordenadas GPS são suficientes :)



Adicione duas variáveis ​​xey, e desenhe o player no local apropriado:



As mudanças que você pode ver aqui . Sobre gitpod não vou lembrar mais :)

Abrir no gitpod



Estágio 4: aka rastreador virtual do primeiro raio do rangefinder


Além das coordenadas do jogador, seria bom saber em que direção ele está olhando. Portanto, adicionamos outra variável player_a, que fornece a direção do olhar do jogador (o ângulo entre a direção do olhar e o eixo da abcissa):



E agora eu quero poder deslizar ao longo do raio laranja. Como fazer isso? Extremamente simples. Vamos olhar para um triângulo retângulo verde. Sabemos que cos (jogador_a) = a / c, e que pecado (jogador_a) = b / c.



O que acontece se eu pegar arbitrariamente o valor de c (positivo) e contar x = player_x + c * cos (player_a) e y = player_y + c * sin (player_a)? Nós nos encontraremos no ponto roxo; variando o parâmetro c de zero a infinito, podemos fazer esse ponto roxo deslizar ao longo de nosso raio laranja ec é a distância de (x, y) a (player_x, player_y)!

O coração do nosso mecanismo gráfico é este ciclo:

  float c = 0; for (; c<20; c+=.05) { float x = player_x + c*cos(player_a); float y = player_y + c*sin(player_a); if (map[int(x)+int(y)*map_w]!=' ') break; } 

Movemos o ponto (x, y) ao longo do raio, se ele se deparar com um obstáculo no mapa, finalizamos o ciclo, e a variável c dá a distância para o obstáculo! O que não é um telêmetro a laser?



As mudanças que você pode ver aqui .

Abrir no gitpod



Etapa 5: Visão Geral do Setor


Um raio é bom, mas ainda assim nossos olhos veem um setor inteiro. Vamos chamar o ângulo de visão fov (campo de visão):



E vamos liberar 512 raios (a propósito, por que 512?), Varrendo suavemente todo o setor de visualização:


As mudanças que você pode ver aqui .

Abrir no gitpod



Etapa 6: 3D!


E agora o ponto chave. Para cada um dos 512 raios, chegamos à distância do obstáculo mais próximo, certo? E agora vamos fazer uma segunda foto com 512 pixels de largura (spoiler); em que para cada raio desenharemos um segmento vertical e a altura do segmento é inversamente proporcional à distância do obstáculo:



Mais uma vez, essa é a chave para criar a ilusão 3D. Certifique-se de entender o que está em jogo. Ao desenhar segmentos verticais, de fato, desenhamos uma cerca de piquete, onde a altura de cada estaca é menor, mais distante está de nós:



As mudanças que você pode ver aqui .

Abrir no gitpod



Etapa 7: Primeira animação


Nesta fase, pela primeira vez, estamos desenhando algo dinâmico (apenas solto 360 fotos no disco). Tudo é trivial: troco jogador_a, faço um desenho, salve, troco jogador_a, desenho, salve. Para torná-lo um pouco mais divertido, designei um valor aleatório de cor para cada tipo de célula em nosso mapa.


As mudanças que você pode ver aqui .

Abrir no gitpod



Etapa 8: correção do olho de peixe


Você notou o grande efeito olho de peixe que observamos quando vemos uma parede de perto? É assim:



Porque Sim, muito simples. Aqui olhamos para a parede:



Para desenhar nosso muro, destacamos nosso setor de visão azul com um raio púrpura. Tome o valor específico da direção do feixe, como nesta figura. O comprimento do segmento laranja é claramente menor que o comprimento do roxo. Como para determinar a altura de cada segmento vertical que desenhamos na tela, dividimos pela distância do obstáculo, o olho de peixe é bastante natural.

Para corrigir essa distorção não é difícil, veja como isso é feito . Por favor, certifique-se de entender de onde veio o cosseno. Desenhar um diagrama em um pedaço de papel ajuda muito.



Abrir no gitpod



Etapa 9: carregar o arquivo de textura


É hora de lidar com texturas. Estou com preguiça de escrever um downloader de imagem, então peguei a excelente biblioteca stb . Eu preparei um arquivo com texturas para as paredes, todas as texturas são quadradas e empacotadas horizontalmente na imagem:



Neste ponto, apenas carrego as texturas na memória. Para testar o código escrito, simplesmente desenho como é a textura com o índice 5 no canto superior esquerdo da tela:


As mudanças que você pode ver aqui .

Abrir no gitpod



Etapa 10: uso rudimentar de texturas


Agora, jogo cores aleatoriamente geradas e tingi minhas paredes pegando o pixel superior esquerdo da textura correspondente:


As mudanças que você pode ver aqui .

Abrir no gitpod



Etapa 11: texturizando as paredes de verdade


E agora chegou o momento tão esperado, quando finalmente vemos as paredes de tijolo:



A idéia básica é muito simples: aqui deslizamos ao longo do raio atual e paramos no ponto x, y. Vamos supor que nos acomodamos em uma parede “horizontal”, então y é quase inteiro (na verdade não, porque nossa maneira de se mover ao longo do raio introduz um pequeno erro). Vamos pegar a parte fracionária de x e chamá-la de hitx. A parte fracionária é menor que uma, portanto, se multiplicarmos hitx pelo tamanho da textura (eu tenho 64), isso nos dará a coluna de textura que precisa ser desenhada neste local. Resta esticá-lo para o tamanho certo e a coisa está no chapéu:



Em geral, a ideia é extremamente primitiva, mas requer execução cuidadosa, pois também temos paredes "verticais" (aquelas com hitx perto de zero [x inteiro]). Para eles, a coluna de textura é determinada pelo hity, a parte fracionária de y. As mudanças que você pode ver aqui .

Abrir no gitpod



Etapa 12: hora de refatorar!


Nesta fase, não fiz nada de novo, apenas comecei a limpeza geral. Até agora, eu tinha um arquivo gigantesco (185 linhas!) E ficou difícil trabalhar nele. Portanto, eu o quebrei em uma nuvem de pequenas dimensões, infelizmente, de passagem, quase dobrando o tamanho do código (319 linhas), sem adicionar nenhuma funcionalidade. Porém, tornou-se muito mais conveniente usar, por exemplo, para gerar uma animação, basta fazer esse loop:

  for (size_t frame=0; frame<360; frame++) { std::stringstream ss; ss << std::setfill('0') << std::setw(5) << frame << ".ppm"; player.a += 2*M_PI/360; render(fb, map, player, tex_walls); drop_ppm_image(ss.str(), fb.img, fb.w, fb.h); } 

Bem, aqui está o resultado:


As mudanças que você pode ver aqui .

Abrir no gitpod

Para continuar ... imediatamente


Nesta nota otimista, termino a metade atual da minha planilha, a segunda metade está disponível aqui . Nele, adicionaremos monstros e criaremos um link para o SDL2 para que você possa passear em nosso mundo virtual.

Source: https://habr.com/ru/post/pt439698/


All Articles