Última atualização:

Sokoban Devlog #01: jogador e movimento

Nawarian
Nawarian jogos

Atenção: o próximo post só vai ser escrito depois de 20 retuítes aqui. Assim você me dá uma força pra crescer o site e me manter motivado a escrever as próximas partes 😉

O post de hoje vai ser bem massa pra quem nunca brincou com desenvolvimento de jogos ou tem vontade de entender como fazer sem nenhuma engine como o Godot, Game Maker, Unity ou outras!

O nosso objetivo é desenhar um personagem na tela e movimentá-lo usando as teclas W,A,S,D.

No post anterior nós explicamos o objetivo deste projetinho e montamos um ambiente de trabalho simples com Make e compilamos uma janela simples, dá uma olhada nele aqui caso tenha perdido. Ele é essencial para acompanhar a série toda.

Conteúdos do post

Gráficos open source: Kenney Sokoban!

Eu não sou lá um designer gráfico, então normalmente eu uso assets de código aberto disponíveis na internet. OpenGameArt e KenneY são dois pontos de partida ótimos para fazer prototipação.

O site KenneY possui um pack específico para jogos do tipo Sokoban, você consegue baixar o pack através deste link. Todos os assets desenvolvidos pelo Kenney ficam disponíveis com a licença Creative Commons (CC0 1.0 Universal), que nos permite fazer de tudo com aquele conteúdo: baixar, utilizar para projetos pessoais e comerciais, modificar... E sem sequer precisar pedir permissão. Tá tudo liberado!

Spritesheet do pacote Sokoban, disponível no site kenney.nl
Spritesheet do pacote Sokoban, disponível no site kenney.nl

Após baixar e extrair o conteúdo, vou mover todos os arquivos do pacote kenney_sokobanpack para um diretório chamado assets/ na raiz do projeto.

$ mkdir assets/
$ mv ~/Downloads/kenney_sokobanpack ./assets/kenney_sokobanpack/

Vários arquivos dentro desta pasta são repetidos e eu não vou me preocupar em limpar a pasta agora. Outros arquivos eu vou fazer questão de manter, como por exemplo o arquivo License.txt e os previews que mantém o logo/assinatura do autor.

Após adicionar o arquivo, faço meu commit em paz e sigo pra próxima etapa: adicionar o esqueleto do jogador!

Compilando o jogador

Pra continuar com tudo simplificado, eu vou separar o esforço de compilar o programa do esforço de programar o programa.

Neste jogo, um jogador possui quatro funções essenciais:

  • create: instancia o objeto Player; Executa antes de o jogo começar
  • update: move o jogador, permite interagir com objetos na tela, etc.; Executa no começo do Game Loop
  • draw: desenha o jogador na tela; Executa entre os métodos Begin e End Drawing(), após todos os updates
  • destroy: libera o espaço de memória utilizado pelo jogador (objetos dinamicamente alocados e sprites); Executa quando saímos do Game Loop

Quando eu escrevi os arquivos, ignorei a função draw(), mas no próximo commit eu adiciono 😉

Eu vou precisar criar dois arquivos: src/player.h e src/player.c. Se você precisa entender o motivo de a gente precisar de dois arquivos pra mesma coisa em C, me dá um toque que eu escrevo sobre. Mas por enquanto, só confia!

Neste projeto, você vai notar que todo arquivo de cabeçalho (.h) segue uma estrutura mais ou menos assim:

#ifndef __nomedoarquivo_h_
#define __nomedoarquivo_h_

// Código

#endif

Essas diretivas (ifndef, define endif) combinadas nos permitem ter certeza de que mesmo se a gente incluir o arquivo de cabeçalho duas vezes no programa, a gente não vai bagunçar com o processo de compilação.

Pois vamos então adicionar o arquivo src/player.h:

#ifndef __player_h_
#define __player_h_

#include "raylib.h"

typedef struct {
    Texture2D texture;
    Rectangle rec;
} Player;

void player_create();
void player_destroy();
void player_update();

#endif

Note que eu criei uma struct com dois campos: Texture2D texture e Rectangle rec. Ambos tipos vem da biblioteca Raylib. O campo texture nos permite guardar uma imagem que será carregada na VRAM, enquanto o campo rec possui atributos de um retângulo: x, y, width e height.

Em seguida, defini a assinatura das três funções que mencionei acima. Todas com o prefixo player_.

Prefixar suas funções em C é uma boa prática e pode te salvar muito tempo no futuro, tanto ao procurar quanto ao refatorar código.

E aqui entra o arquivo src/player.c, que implementa as funções prototipadas acima:

#include "raylib.h"

#include "player.h"

Player p;

void player_create()
{
}

void player_destroy()
{
}

void player_update()
{
}

Note que nós fizemos o include do arquivo player.h. Sem este include, o tipo Player não seria  encontrado e o nosso arquivo player.c não seria nunca compilado.

Como eu disse antes, eu esqueci de escrever a função void player_draw() e corrijo isto no próximo passo.

Note que a variável Player p foi definida no escopo global. Esta é a única forma de manter a variável p acessível por todas as funções player_*.

Naturalmente, precisamos utilizar as novas funções. Vamos modificar a função main() dentro do arquivo sokoban.c:

#include "raylib.h"
#include "player.h" // struct Player, funções player_*

int main(void)
{
// ...
player_create(); // instancia o jogador antes de entrar no Loop

while (!WindowShouldClose()) {
player_update(); // movimentos, itens, pontuação...

// ...
}

player_destroy();

// ...
}

O último passo é amarrar os novos arquivos no processo de compilação. Para isto, vamos modificar uma única linha do arquivo Makefile:

OBJS=$(OUT_DIR)/player.o $(OUT_DIR)/sokoban.o

Se você se lembra bem, a regra all do nosso Makefile utiliza a variável OBJS como entrada do nosso processo de compilação. Sempre que quisermos adicionar um objeto .o ao projeto principal, basta adicioná-lo a esta lista.

O commit que representa esta seção você encontra aqui. O resultado por enquanto é isso aqui:

Linha de comando apresenta as chamadas de compilador incluindo o arquivo src/player.c e build/player.o
Linha de comando apresenta as chamadas de compilador incluindo o arquivo src/player.c e build/player.o

Vamos amarrar o sprite ao jogador

Primeiro, deixa eu criar a função void player_draw() que eu falhei em colocar da última vez:

src/player.h

// ...
void
player_destroy();
void player_draw();
// ...

src/player.c

// ...
void player_draw()
{
}

void player_update()
{
// ...
}

src/sokoban.c

// ...
BeginDrawing();
ClearBackground(BLACK);

player_draw();

DrawFPS(0, 0);
EndDrawing();
// ...

Outra correção que eu preciso fazer, é sobre a variável global Player p. Eu preciso ser capaz de alocar esta variável de forma dinâmica (usando malloc). Portanto eu vou mudar a declaração da variável para que seja um ponteiro. Para declarar uma variável como ponteiro, basta prefixar seu nome com um asterisco:

src/player.c

// ...

Player *p;

// ...

Se você compilar e rodar agora, absolutamente NADA deverá ter mudado. Vamos então implementar as funções player_* uma por uma para dar vida ao nosso jogo.

player_create()

A variável p está em escopo global. Nós só precisamos alocá-la em memória, carregar a textura e dar uma posição para o jogador. O meio da tela está bom por agora.

Aqui fica a implementação completa da função player_create:

void player_create()
{
p = (Player *) malloc(sizeof(Player));

p->texture = LoadTexture("assets/kenney_sokobanpack/PNG/Retina/Player/player_03.png");
p->rec = (Rectangle) {
GetScreenWidth() / 2 - p->texture.width / 2,
GetScreenHeight() / 2 - p->texture.height / 2,
128,
128
}
}

qq tá cotecenu? Vamos por partes:

p = (Player *) malloc(sizeof(Player));

Antes desta linha, nós tínhamos algo como Player *p;, que declara a existência de um ponteiro. Mas na verdade este ponteiro é NULL e não está pronto para guardar valor nenhum. Em outras palavras, o compilador ainda não nos deu memória para guardar um objeto do tipo struct Player.

A função malloc() nos permite alocar memória de forma dinâmica. O parâmetro desta função é o tamanho (em bytes) de memória que eu quero. No caso, eu pedi espaço o suficiente para guardar um objeto do tipo struct Player.

O retorno da função malloc é um endereço de memória reservado para nós, que tem exatamente aquele tamanho.

O cast (Player *) serve apenas para dizer ao compilador que a gente pode confiar que aquele pedaço de memória pode ser tratado como um struct Player.

O resto é bem fácil de explicar.

p->texture = LoadTexture(".../player_03.png");

Carregamos para a VRAM o arquivo player_03.png do sokoban pack. A função LoadTexture vem da Raylib.

p->rec = (Rectangle) { 0 }

Aqui nós montamos o retângulo que representa o jogador. Rectangle é uma struct que a Raylib fornece. Seu formato é { float x; float y; float width; float height }.

O tamanho da textura é de 128x128, portanto o que estamos fazendo é posicionar o jogador no meio da tela.

player_destroy()

O mais fácil de explicar agora, é a função destroy() que deverá ser chamada quando saímos do Game Loop (quando você pressionar ESC).

Nós precisamos limpar dois espaços de memória importantíssimos: a textura que carregamos e o próprio objeto Player *p.

Quando você solicitar um pedaço de memória de forma dinâmica, usando malloc(), lembre-se SEMPRE de limpar este espaço de memória depois, usando free(). Do contrário você vai gerar memory leaks que são difíceis de encontrar.

E é assim que nós fazemos:

void player_destroy()
{
UnloadTexture(p->texture);
free(p);
}

Note que nós limpamos p->texture ANTES de limpar p. Isto porque p->texture é só um ponteiro para uma tabela da própria Raylib. Se nós destruíssemos a variável p antes, não teríamos mais acesso ao identificador da textura e portanto ela ficaria em memória para sempre, apesar de não ser utilizada.

player_draw()

void player_draw()
{
if (p == NULL) {
return;
}

DrawTexture(
p->texture,
p->rec.x,
p->rec.y,
WHITE
);
}

Primeiro verificamos a possibilidade de p ser um ponteiro nulo. Desta vez nós evitamos um segmentation fault caso qualquer bug estranho acabe limpando a variável p antes de chamar a função player_draw().

Depois utilizamos a função void DrawTexture(Texture2D tex, float x, float y, Color tint) da biblioteca Raylib. Isto deverá desenhar a textura inteira (128x128) na tela, nas coordenadas (p->rec.x; p->rec.y).

A partir daqui já podemos ver algum resultado:

Maaassa!!

Mas ainda não adicionamos movimento ao jogador. Vamos mexer no update.

player_update()

Vamos assumir algumas coisas aqui:

  • A textura tem dimensões de 128x128. Portanto para parecer que o jogador andou para qualquer direção, precisamos o deslocar em 128 pixels naquela direção;
  • Não nos interessa fazer um movimento "macio"

A Raylib tem uma função muito útil chamada IsKeyPressed, com a seguinte assinatura: bool IsKeyPressed(int key). Ela também tem um tipo chamado Vector2, que é um struct com os seguintes campos: { float x, float y }.

Um valor bool também pode ser representado como 0 ou 1. Portanto 10 - true teoricamente é igual a 9.

Então vamos ao código e eu te explico o o que eu fiz:

void player_update()
{
Vector2 delta = { 0 };

delta.x = (IsKeyPressed(KEY_D) - IsKeyPressed(KEY_A)) * 128;
delta.y = (IsKeyPressed(KEY_S) - IsKeyPressed(KEY_W)) * 128;

p->rec.x += delta.x;
p->rec.y += delta.y;
}

Vector2 delta = { 0 }; aqui nós apenas criamos uma variável do tipo struct Vector2 com o valor x = 0 e y = 0.

Em seguida fizemos uma matemática simples para a movimentação horizontal e vertical. Os resultados de IsKeyPressed(KEY_*) - IsKeyPressed(KEY_*) sempre serão 1, 0 ou -1. Veja a tabela abaixo:

Tabela verdade para a subtração IsKeyPressed() - IsKeyPressed()
Expressão (bool)Equivalente (int)Valor
true - false1 - 01
false - false0 - 00
true - true1 - 10
false - true0 - 1-1

Portanto ao multiplicar este resultado por 128, temos um movimento que representa 128, 0 ou -128 pixels.

O resultado deste cálculo estamos armazenando na variável delta.

Depois somamos delta.x e delta.y ao p->rec (retângulo que representa o jogador). E com isso temos o movimento preparado. Vamos esclarecer com um pseudo caso de testes:

// Supondo que p->rec está nas coordenadas (100, 100)
p->rec.x = 100;
p->rec.y = 100;

// Se eu pressionar a tecla A e nenhuma outra tecla
delta.x = (false - true) * 128; // -1 * 128 = -128
delta.y = (false - false) * 128; // 0 * 128 = 0

// Então a posição do jogador deverá ser (-28, 100)
p->rec.x += delta.x; // 100 + (-128) = -28
p->rec.y += delta.y; // 100 + 0 = 0

E aqui você confere o resultado na íntegra:

GIF animado ilustrando o resultado do post de hoje: um personagem se movendo pela tela.
GIF animado ilustrando o resultado do post de hoje: um personagem se movendo pela tela.

Depois eu disso eu só dei uma limpadinha no código pra remover aquele número 128 mágico de lá e usar variáveis em vez disso.

O Pull Request todo você encontra aqui: Terrario/sokobão PR #2.

Próximos passos

Bom, nós já criamos o jogador. Agora precisamos criar alguns obstáculos na tela.

No próximo post vamos implementar um bloco simples. E a partir disso vamos trabalhar com colisão: um jogador não pode atravessar um bloco.

Mas eu preciso da sua ajuda pra desenvolver o próximo post. Preciso que você divulgue este post aqui. Quando eu alcançar 20 retuítes aqui, começo a escrever o próximo post.

Desta forma você me ajuda a popularizar o blog e ajuda outras pessoas encontrar este conteúdo massa!

Até a próxima! 👋

Comentários