Criando um FPS com C e raylib: Da Primeira Janela ao Tiro no Inimigo

Criando um FPS com C e raylib: Da Primeira Janela ao Tiro no Inimigo

Este artigo mostra como criar um shooter em primeira pessoa (FPS) com C. Ele usa o raylib, uma biblioteca de engine de código aberto, para janela, gráficos 3D e entrada. A lógica do jogo, incluindo mapa, colisão com paredes, tiros e HUD, é escrita em C. Cada parte são passos que você segue um de cada vez, com um diagrama da estrutura e arquivos de código que você pode copiar.

O que você precisa

Você precisa de um computador com Windows 10 ou 11, do Visual Studio 2022 Build Tools com o conjunto de ferramentas C++ (MSVC) e o Windows SDK, e do raylib baixado da página de GitHub Releases. O raylib é distribuído sob a licença zlib, que permite uso comercial. Nota: o código aqui foi escrito com base na documentação do raylib 5 e 6, mas o programa completo ainda não foi compilado e testado na máquina usada para escrever este artigo. Os resultados indicados são o que você deve ver se seguir os passos corretamente. A fonte padrão do raylib exibe apenas letras latinas, então os textos na tela dos exemplos estão em inglês.

Ilustração

Laço do jogo a cada quadro: ler a entrada, atualizar, desenhar e então exibir o quadro
Laço do jogo a cada quadro: ler a entrada, atualizar, desenhar e então exibir o quadro

1. Instale as ferramentas e o raylib

Use o MSVC para compilar o C, e o raylib como biblioteca de engine de código aberto.

1. Baixe o Visual Studio 2022 Build Tools da Microsoft e instale a carga de trabalho Desenvolvimento para desktop com C++

2. Abra o Developer Command Prompt for VS 2022 pelo menu Iniciar

3. Acesse github.com/raysan5/raylib/releases e baixe o arquivo para Windows cujo nome termina em win64_msvc16.zip

4. Extraia o arquivo e copie as pastas include e lib para uma pasta do projeto chamada raylib

O que você deve ver: a pasta raylib/include contém raylib.h, e a pasta raylib/lib contém raylib.lib

2. Estrutura do projeto

Guarde o código do jogo na pasta src e mantenha a biblioteca raylib separada.

Código: project-tree.txt

fps-c-raylib/
├── raylib/                 biblioteca raylib baixada do GitHub Releases
│   ├── include/raylib.h
│   └── lib/raylib.lib
├── src/
│   └── main.c              todo o código do jogo
├── build.bat               comando de compilação para o MSVC
└── fps-c-raylib.exe        arquivo compilado

3. Abra uma janela e desenhe texto

Todo jogo com raylib segue o mesmo laço: abrir a janela, repetir até a janela ser fechada e desenhar um quadro a cada vez.

1. Crie a pasta src e um arquivo chamado main.c dentro dela

2. Cole o código abaixo em main.c

Código: 01_window.c

// Arquivo: src/main.c  (primeira etapa, abre a janela e desenha texto)
#include "raylib.h"

int main(void)
{
    InitWindow(960, 540, "FPS com C e raylib");
    SetTargetFPS(60);

    while (!WindowShouldClose())
    {
        BeginDrawing();
        ClearBackground(RAYWHITE);
        DrawText("Olá, FPS", 40, 40, 32, DARKGRAY);
        EndDrawing();
    }

    CloseWindow();
    return 0;
}

O que você deve ver: uma janela de 960x540 com fundo branco e o texto Olá, FPS no canto superior esquerdo

4. Desenhe o mapa como paredes 3D

Guarde o mapa como uma matriz de números 1 e 0. O número 1 é parede e o 0 é espaço livre. Depois percorra a matriz e desenhe um cubo para cada célula de parede.

Código: 02_map_render.c

// Arquivo: src/main.c  (segunda etapa, o mapa é uma tabela de números e as paredes são desenhadas como cubos 3D)
#include "raylib.h"

#define MAP_W 8
#define MAP_H 8
#define CELL 4.0f // tamanho de uma célula no mundo 3D

// 1 = parede, 0 = espaço livre
static const int map[MAP_H][MAP_W] = {
    {1, 1, 1, 1, 1, 1, 1, 1},
    {1, 0, 0, 0, 0, 0, 0, 1},
    {1, 0, 1, 1, 0, 0, 0, 1},
    {1, 0, 0, 0, 0, 1, 0, 1},
    {1, 0, 0, 1, 0, 1, 0, 1},
    {1, 0, 0, 0, 0, 0, 0, 1},
    {1, 1, 1, 1, 1, 1, 1, 1},
    {1, 1, 1, 1, 1, 1, 1, 1},
};

static void DrawMap(void)
{
    for (int row = 0; row < MAP_H; row++)
    {
        for (int col = 0; col < MAP_W; col++)
        {
            if (map[row][col] == 1)
            {
                // a célula (col, row) cobre a área [col*CELL, (col+1)*CELL], então o centro fica em +CELL/2
                Vector3 pos = { (col + 0.5f) * CELL, CELL / 2.0f, (row + 0.5f) * CELL };
                DrawCube(pos, CELL, CELL, CELL, DARKGRAY);
                DrawCubeWires(pos, CELL, CELL, CELL, BLACK);
            }
        }
    }
}

int main(void)
{
    InitWindow(960, 540, "Mapa do FPS");
    SetTargetFPS(60);

    Camera3D camera = { 0 };
    camera.position = (Vector3){ 6.0f, 2.0f, 6.0f };
    camera.target = (Vector3){ 10.0f, 1.0f, 10.0f };
    camera.up = (Vector3){ 0.0f, 1.0f, 0.0f };
    camera.fovy = 60.0f;
    camera.projection = CAMERA_PERSPECTIVE;

    while (!WindowShouldClose())
    {
        BeginDrawing();
        ClearBackground(SKYBLUE);

        BeginMode3D(camera);
        DrawPlane((Vector3){ 16.0f, 0.0f, 16.0f }, (Vector2){ 32.0f, 32.0f }, LIGHTGRAY);
        DrawMap();
        EndMode3D();

        DrawFPS(10, 10);
        EndDrawing();
    }

    CloseWindow();
    return 0;
}

O que você deve ver: paredes cinza-escuras formam o mapa em 3D, e o contador de FPS aparece no canto superior esquerdo

5. Ande e gire com WASD e mouse

O UpdateCamera do raylib oferece CAMERA_FIRST_PERSON, que usa WASD para andar e o mouse para olhar ao redor, então você não precisa escrever os cálculos de ângulo.

Código: 03_movement.c

// Arquivo: src/main.c  (terceira etapa, anda e gira a câmera com teclado e mouse)
#include "raylib.h"

int main(void)
{
    InitWindow(960, 540, "Andando pelo mapa");
    SetTargetFPS(60);
    DisableCursor(); // esconde o cursor para girar a câmera com o mouse

    Camera3D camera = { 0 };
    camera.position = (Vector3){ 6.0f, 2.0f, 6.0f };
    camera.target = (Vector3){ 10.0f, 1.0f, 10.0f };
    camera.up = (Vector3){ 0.0f, 1.0f, 0.0f };
    camera.fovy = 60.0f;
    camera.projection = CAMERA_PERSPECTIVE;

    while (!WindowShouldClose())
    {
        // WASD para andar e o mouse para girar a visão, funciona com o modo CAMERA_FIRST_PERSON
        UpdateCamera(&camera, CAMERA_FIRST_PERSON);

        BeginDrawing();
        ClearBackground(SKYBLUE);
        BeginMode3D(camera);
        DrawPlane((Vector3){ 16.0f, 0.0f, 16.0f }, (Vector2){ 32.0f, 32.0f }, LIGHTGRAY);
        EndMode3D();
        DrawText("WASD move, mouse look", 10, 40, 20, DARKGRAY);
        EndDrawing();
    }

    CloseWindow();
    return 0;
}

O que você deve ver: as teclas W A S D movem a câmera e o mouse gira a visão. O cursor fica escondido.

6. Verifique a colisão com a parede

Verifique os quatro cantos ao redor do jogador antes de aceitar a nova posição. Se um canto bater em uma parede, cancele apenas aquele eixo, para que o jogador deslize ao longo da parede. Desloque o ponto de mira com a mesma quantidade, para que a direção do olhar não mude.

Código: 04_collision.c

// Arquivo: src/main.c  (quarta etapa, verifica colisão com a parede antes de aceitar o movimento)
#include "raylib.h"
#include "raymath.h"

#define MAP_W 8
#define MAP_H 8
#define CELL 4.0f
#define PLAYER_RADIUS 0.4f

static const int map[MAP_H][MAP_W] = {
    {1, 1, 1, 1, 1, 1, 1, 1},
    {1, 0, 0, 0, 0, 0, 0, 1},
    {1, 0, 1, 1, 0, 0, 0, 1},
    {1, 0, 0, 0, 0, 1, 0, 1},
    {1, 0, 0, 1, 0, 1, 0, 1},
    {1, 0, 0, 0, 0, 0, 0, 1},
    {1, 1, 1, 1, 1, 1, 1, 1},
    {1, 1, 1, 1, 1, 1, 1, 1},
};

// Verifica se o ponto (x, z) do mundo está dentro de uma célula de parede
static bool IsWall(float x, float z)
{
    int col = (int)(x / CELL);
    int row = (int)(z / CELL);
    if (col < 0 || col >= MAP_W || row < 0 || row >= MAP_H) return true;
    return map[row][col] == 1;
}

// Verifica os quatro cantos ao redor do jogador. Se algum canto estiver em uma parede, a posição não pode ser usada
static bool CanMoveTo(Vector3 p)
{
    float r = PLAYER_RADIUS;
    return !IsWall(p.x - r, p.z - r) && !IsWall(p.x + r, p.z - r) &&
           !IsWall(p.x - r, p.z + r) && !IsWall(p.x + r, p.z + r);
}

int main(void)
{
    InitWindow(960, 540, "Batendo na parede");
    SetTargetFPS(60);
    DisableCursor();

    Camera3D camera = { 0 };
    camera.position = (Vector3){ 6.0f, 2.0f, 6.0f };
    camera.target = (Vector3){ 10.0f, 1.0f, 10.0f };
    camera.up = (Vector3){ 0.0f, 1.0f, 0.0f };
    camera.fovy = 60.0f;
    camera.projection = CAMERA_PERSPECTIVE;

    while (!WindowShouldClose())
    {
        // guarda a posição antes de se mover, depois deixa o UpdateCamera calcular a nova posição
        Vector3 before = camera.position;
        UpdateCamera(&camera, CAMERA_FIRST_PERSON);

        // se a nova posição bater na parede, volta apenas no eixo que bateu, para deslizar ao longo da parede
        Vector3 wanted = camera.position;
        Vector3 fixed = wanted;
        if (!CanMoveTo((Vector3){ fixed.x, fixed.y, before.z })) fixed.x = before.x;
        if (!CanMoveTo(fixed)) fixed.z = before.z;

        // desloca também o ponto de mira para que a direção do olhar não mude
        Vector3 shift = Vector3Subtract(fixed, wanted);
        camera.position = fixed;
        camera.target = Vector3Add(camera.target, shift);

        BeginDrawing();
        ClearBackground(SKYBLUE);
        BeginMode3D(camera);
        DrawPlane((Vector3){ 16.0f, 0.0f, 16.0f }, (Vector2){ 32.0f, 32.0f }, LIGHTGRAY);
        for (int row = 0; row < MAP_H; row++)
            for (int col = 0; col < MAP_W; col++)
                if (map[row][col] == 1)
                {
                    Vector3 pos = { (col + 0.5f) * CELL, CELL / 2.0f, (row + 0.5f) * CELL };
                    DrawCube(pos, CELL, CELL, CELL, DARKGRAY);
                }
        EndMode3D();
        DrawText("Walls block you", 10, 40, 20, DARKGRAY);
        EndDrawing();
    }

    CloseWindow();
    return 0;
}

O que você deve ver: andar contra uma parede para você, sem atravessá-la, e você continua podendo deslizar ao longo dela.

Tiro com raio: o raio do centro da tela atinge a caixa do inimigo, e o resultado mais próximo é o alvo
Tiro com raio: o raio do centro da tela atinge a caixa do inimigo, e o resultado mais próximo é o alvo

7. Atire com um raio a partir do centro da tela

GetMouseRay cria um raio a partir de um ponto da tela. Este exemplo usa o centro da tela, e depois GetRayCollisionBox verifica se o raio atinge a caixa do inimigo.

Código: 05_shoot.c

// Arquivo: src/main.c  (quinta etapa, tiro com um raio a partir do mouse que atinge a caixa do inimigo)
#include "raylib.h"

// caixa delimitadora de um inimigo
static BoundingBox enemyBox = {
    { 14.0f, 0.0f, 14.0f }, // canto mínimo
    { 15.5f, 2.5f, 15.5f }, // canto máximo
};
static bool enemyAlive = true;

int main(void)
{
    InitWindow(960, 540, "Atire no inimigo");
    SetTargetFPS(60);
    DisableCursor();

    Camera3D camera = { 0 };
    camera.position = (Vector3){ 6.0f, 2.0f, 6.0f };
    camera.target = (Vector3){ 14.0f, 1.0f, 14.0f };
    camera.up = (Vector3){ 0.0f, 1.0f, 0.0f };
    camera.fovy = 60.0f;
    camera.projection = CAMERA_PERSPECTIVE;

    while (!WindowShouldClose())
    {
        UpdateCamera(&camera, CAMERA_FIRST_PERSON);

        // clique com o botão esquerdo para disparar um raio do centro da tela para a frente
        if (IsMouseButtonPressed(MOUSE_BUTTON_LEFT) && enemyAlive)
        {
            Ray ray = GetMouseRay((Vector2){ GetScreenWidth() / 2.0f, GetScreenHeight() / 2.0f }, camera);
            RayCollision hit = GetRayCollisionBox(ray, enemyBox);
            if (hit.hit)
            {
                enemyAlive = false; // acertou, o inimigo desaparece
            }
        }

        BeginDrawing();
        ClearBackground(SKYBLUE);
        BeginMode3D(camera);
        DrawPlane((Vector3){ 16.0f, 0.0f, 16.0f }, (Vector2){ 32.0f, 32.0f }, LIGHTGRAY);
        if (enemyAlive)
        {
            Vector3 center = { 14.75f, 1.25f, 14.75f };
            DrawCubeV(center, (Vector3){ 1.5f, 2.5f, 1.5f }, RED);
        }
        EndMode3D();

        DrawLine(GetScreenWidth() / 2 - 10, GetScreenHeight() / 2, GetScreenWidth() / 2 + 10, GetScreenHeight() / 2, BLACK);
        DrawLine(GetScreenWidth() / 2, GetScreenHeight() / 2 - 10, GetScreenWidth() / 2, GetScreenHeight() / 2 + 10, BLACK);
        EndDrawing();
    }

    CloseWindow();
    return 0;
}

O que você deve ver: mire no cubo vermelho e clique com o botão esquerdo para ele desaparecer. Atirar para outro lado não acontece nada.

8. HUD com munição e vida

O HUD desenha texto e barras em 2D depois que EndMode3D é chamado. A quantidade de munição diminui a cada tiro.

Código: 06_hud.c

// Arquivo: src/main.c  (sexta etapa, o HUD mostra munição e vida)
#include <stdio.h>
#include "raylib.h"

typedef struct
{
    int ammo;
    int health;
} Player;

static void DrawHud(const Player *p)
{
    char text[64];

    // barra de vida no canto inferior esquerdo
    DrawRectangle(20, GetScreenHeight() - 40, 200, 20, DARKGRAY);
    DrawRectangle(20, GetScreenHeight() - 40, (int)(200 * p->health / 100.0f), 20, RED);

    // quantidade de munição no canto inferior direito
    snprintf(text, sizeof(text), "Ammo %d", p->ammo);
    DrawText(text, GetScreenWidth() - 180, GetScreenHeight() - 40, 24, BLACK);
}

int main(void)
{
    InitWindow(960, 540, "HUD");
    SetTargetFPS(60);

    Player player = { .ammo = 12, .health = 100 };

    while (!WindowShouldClose())
    {
        if (IsMouseButtonPressed(MOUSE_BUTTON_LEFT) && player.ammo > 0)
        {
            player.ammo--; // um tiro diminui a munição
        }

        BeginDrawing();
        ClearBackground(SKYBLUE);
        DrawHud(&player);
        EndDrawing();
    }

    CloseWindow();
    return 0;
}

O que você deve ver: o canto inferior direito mostra Ammo 12, e a barra vermelha no canto inferior esquerdo mostra a vida cheia. A munição diminui 1 a cada clique.

9. Juntando tudo

Este arquivo combina o mapa, a colisão com as paredes, dois inimigos com vida, tiro no inimigo mais próximo e o HUD.

Código: 07_full_main.c

// Arquivo: src/main.c  (todas as etapas juntas em um único arquivo)
#include <stdio.h>
#include "raylib.h"
#include "raymath.h"

#define MAP_W 8
#define MAP_H 8
#define CELL 4.0f
#define PLAYER_RADIUS 0.4f
#define ENEMY_COUNT 2

static const int map[MAP_H][MAP_W] = {
    {1, 1, 1, 1, 1, 1, 1, 1},
    {1, 0, 0, 0, 0, 0, 0, 1},
    {1, 0, 1, 1, 0, 0, 0, 1},
    {1, 0, 0, 0, 0, 1, 0, 1},
    {1, 0, 0, 1, 0, 1, 0, 1},
    {1, 0, 0, 0, 0, 0, 0, 1},
    {1, 1, 1, 1, 1, 1, 1, 1},
    {1, 1, 1, 1, 1, 1, 1, 1},
};

typedef struct
{
    BoundingBox box;
    int health;
} Enemy;

typedef struct
{
    int ammo;
    int health;
} Player;

static bool IsWall(float x, float z)
{
    int col = (int)(x / CELL);
    int row = (int)(z / CELL);
    if (col < 0 || col >= MAP_W || row < 0 || row >= MAP_H) return true;
    return map[row][col] == 1;
}

static bool CanMoveTo(Vector3 p)
{
    float r = PLAYER_RADIUS;
    return !IsWall(p.x - r, p.z - r) && !IsWall(p.x + r, p.z - r) &&
           !IsWall(p.x - r, p.z + r) && !IsWall(p.x + r, p.z + r);
}

static void ApplyCollision(Camera3D *camera, Vector3 before)
{
    Vector3 wanted = camera->position;
    Vector3 fixed = wanted;
    if (!CanMoveTo((Vector3){ fixed.x, fixed.y, before.z })) fixed.x = before.x;
    if (!CanMoveTo(fixed)) fixed.z = before.z;
    Vector3 shift = Vector3Subtract(fixed, wanted);
    camera->position = fixed;
    camera->target = Vector3Add(camera->target, shift);
}

static void DrawMap(void)
{
    for (int row = 0; row < MAP_H; row++)
    {
        for (int col = 0; col < MAP_W; col++)
        {
            if (map[row][col] == 1)
            {
                Vector3 pos = { (col + 0.5f) * CELL, CELL / 2.0f, (row + 0.5f) * CELL };
                DrawCube(pos, CELL, CELL, CELL, DARKGRAY);
                DrawCubeWires(pos, CELL, CELL, CELL, BLACK);
            }
        }
    }
}

int main(void)
{
    InitWindow(960, 540, "FPS com C e raylib");
    SetTargetFPS(60);
    DisableCursor();

    Camera3D camera = { 0 };
    camera.position = (Vector3){ 6.0f, 2.0f, 6.0f };
    camera.target = (Vector3){ 10.0f, 1.0f, 10.0f };
    camera.up = (Vector3){ 0.0f, 1.0f, 0.0f };
    camera.fovy = 60.0f;
    camera.projection = CAMERA_PERSPECTIVE;

    Player player = { .ammo = 12, .health = 100 };
    Enemy enemies[ENEMY_COUNT] = {
        { { { 14.0f, 0.0f, 14.0f }, { 15.5f, 2.5f, 15.5f } }, 50 },
        { { { 18.0f, 0.0f, 4.0f }, { 19.5f, 2.5f, 5.5f } }, 50 },
    };

    while (!WindowShouldClose())
    {
        Vector3 before = camera.position;
        UpdateCamera(&camera, CAMERA_FIRST_PERSON);
        ApplyCollision(&camera, before);

        if (IsMouseButtonPressed(MOUSE_BUTTON_LEFT) && player.ammo > 0)
        {
            player.ammo--;
            Vector2 center = { GetScreenWidth() / 2.0f, GetScreenHeight() / 2.0f };
            Ray ray = GetMouseRay(center, camera);

            // atinge o inimigo mais próximo que o raio alcança
            int target = -1;
            float best = 1e9f;
            for (int i = 0; i < ENEMY_COUNT; i++)
            {
                if (enemies[i].health <= 0) continue;
                RayCollision hit = GetRayCollisionBox(ray, enemies[i].box);
                if (hit.hit && hit.distance < best)
                {
                    best = hit.distance;
                    target = i;
                }
            }
            if (target >= 0) enemies[target].health -= 25;
        }

        BeginDrawing();
        ClearBackground(SKYBLUE);
        BeginMode3D(camera);
        DrawPlane((Vector3){ 16.0f, 0.0f, 16.0f }, (Vector2){ 32.0f, 32.0f }, LIGHTGRAY);
        DrawMap();
        for (int i = 0; i < ENEMY_COUNT; i++)
        {
            if (enemies[i].health <= 0) continue;
            Vector3 c = {
                (enemies[i].box.min.x + enemies[i].box.max.x) / 2.0f,
                1.25f,
                (enemies[i].box.min.z + enemies[i].box.max.z) / 2.0f,
            };
            DrawCubeV(c, (Vector3){ 1.5f, 2.5f, 1.5f }, RED);
        }
        EndMode3D();

        // mira no centro da tela
        DrawLine(GetScreenWidth() / 2 - 10, GetScreenHeight() / 2, GetScreenWidth() / 2 + 10, GetScreenHeight() / 2, BLACK);
        DrawLine(GetScreenWidth() / 2, GetScreenHeight() / 2 - 10, GetScreenWidth() / 2, GetScreenHeight() / 2 + 10, BLACK);

        // HUD
        char text[64];
        snprintf(text, sizeof(text), "Ammo %d", player.ammo);
        DrawText(text, GetScreenWidth() - 180, GetScreenHeight() - 40, 24, BLACK);
        DrawRectangle(20, GetScreenHeight() - 40, 200, 20, DARKGRAY);
        DrawRectangle(20, GetScreenHeight() - 40, (int)(200 * player.health / 100.0f), 20, RED);
        DrawFPS(10, 10);
        EndDrawing();
    }

    CloseWindow();
    return 0;
}

O que você deve ver: você anda dentro da sala com paredes e atira nos inimigos vermelhos. Cada inimigo precisa de dois tiros para desaparecer, e a munição diminui a cada tiro.

10. Compile e execute

Use o comando cl no Developer Command Prompt para compilar e vincular com raylib.lib.

1. Abra o Developer Command Prompt for VS 2022 e entre na pasta do projeto

2. Execute o comando de compilação do bloco abaixo

3. Execute o fps-c-raylib.exe gerado

cl /nologo /W3 /I raylib\include src\main.c /link /LIBPATH:raylib\lib raylib.lib winmm.lib gdi32.lib user32.lib shell32.lib

O que você deve ver: nenhum erro na compilação, e a janela do jogo abre quando o .exe é executado.

Próximos passos

Em seguida, acrescente o som do tiro, inimigos que caminham até o jogador, um menu inicial e carregar o mapa de um arquivo de texto. Outros exemplos estão em github.com/raysan5/raylib/tree/master/examples.

Resumo

Use o raylib como engine de código aberto com C, e escreva você mesmo o mapa, o movimento, a colisão com paredes, o tiro com raio e o HUD. Compile com o MSVC para gerar um único .exe. Cada passo pode ser seguido em ordem.