Membuat FPS dengan C dan raylib: Dari Jendela Pertama sampai Menembak Musuh

Membuat FPS dengan C dan raylib: Dari Jendela Pertama sampai Menembak Musuh

Artikel ini menunjukkan cara membuat penembak orang pertama (FPS) dengan C. Ia memakai raylib, pustaka mesin open-source, untuk jendela, grafik 3D, dan input. Logika game, termasuk peta, tabrakan dinding, menembak, dan HUD, ditulis dengan C. Setiap bagian berupa langkah-langkah yang bisa Anda ikuti satu per satu, dengan diagram struktur dan berkas kode yang bisa Anda salin.

Yang Anda butuhkan

Anda memerlukan komputer Windows 10 atau 11, Visual Studio 2022 Build Tools dengan toolset C++ (MSVC) dan Windows SDK, serta raylib yang diunduh dari halaman GitHub Releases. raylib dirilis dengan lisensi zlib, yang mengizinkan penggunaan komersial. Catatan: kode di sini ditulis berdasarkan dokumentasi raylib 5 dan 6, tetapi program lengkapnya belum dikompilasi dan diuji di mesin yang dipakai untuk menulis artikel ini. Hasil yang tercantum adalah apa yang seharusnya Anda lihat jika langkah-langkahnya dikerjakan dengan benar. Font bawaan raylib hanya menampilkan huruf Latin, jadi teks di layar dalam contoh memakai bahasa Inggris.

Ilustrasi

Loop game di setiap frame: baca input, perbarui, gambar, lalu tampilkan frame
Loop game di setiap frame: baca input, perbarui, gambar, lalu tampilkan frame

1. Pasang alat dan raylib

Gunakan MSVC untuk mengompilasi C, dan raylib sebagai pustaka mesin open-source.

1. Unduh Visual Studio 2022 Build Tools dari Microsoft dan pasang beban kerja Desktop development with C++

2. Buka Developer Command Prompt for VS 2022 dari menu Start

3. Buka github.com/raysan5/raylib/releases dan unduh berkas Windows yang namanya berakhiran win64_msvc16.zip

4. Ekstrak berkasnya dan salin folder include dan lib ke dalam folder proyek bernama raylib

Yang seharusnya Anda lihat: Folder raylib/include berisi raylib.h, dan folder raylib/lib berisi raylib.lib

2. Struktur proyek

Simpan kode game di folder src dan letakkan pustaka raylib secara terpisah.

Kode: project-tree.txt

fps-c-raylib/
├── raylib/                 pustaka raylib yang diunduh dari GitHub Releases
│   ├── include/raylib.h
│   └── lib/raylib.lib
├── src/
│   └── main.c              seluruh kode game
├── build.bat               perintah kompilasi untuk MSVC
└── fps-c-raylib.exe        berkas hasil kompilasi

3. Buka jendela dan gambar teks

Setiap game raylib mengikuti loop yang sama: buka jendela, terus berulang sampai jendela ditutup, dan gambar satu frame setiap kali.

1. Buat folder src dan berkas bernama main.c di dalamnya

2. Tempel kode di bawah ke dalam main.c

Kode: 01_window.c

// Berkas: src/main.c  (tahap pertama, membuka jendela dan menggambar teks)
#include "raylib.h"

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

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

    CloseWindow();
    return 0;
}

Yang seharusnya Anda lihat: Jendela 960x540 terbuka dengan latar putih dan teks Hello FPS di pojok kiri atas

4. Gambar peta sebagai dinding 3D

Simpan peta sebagai larik angka 1 dan 0. Angka 1 adalah dinding dan angka 0 adalah ruang kosong. Lalu telusuri larik itu dan gambar sebuah kotak untuk setiap sel dinding.

Kode: 02_map_render.c

// Berkas: src/main.c  (tahap kedua, peta berupa tabel angka lalu dinding digambar sebagai kotak 3D)
#include "raylib.h"

#define MAP_W 8
#define MAP_H 8
#define CELL 4.0f // ukuran satu sel di dunia 3D

// 1 = dinding, 0 = ruang kosong
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)
            {
                // sel (col, row) mencakup area [col*CELL, (col+1)*CELL] sehingga pusatnya di +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, "Peta 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;
}

Yang seharusnya Anda lihat: Dinding abu-abu gelap membentuk peta dalam 3D, dan penghitung FPS muncul di pojok kiri atas

5. Berjalan dan memutar dengan WASD dan mouse

UpdateCamera dari raylib mendukung CAMERA_FIRST_PERSON, yang memakai WASD untuk berjalan dan mouse untuk melihat sekeliling, sehingga Anda tidak perlu menulis perhitungan sudut sendiri.

Kode: 03_movement.c

// Berkas: src/main.c  (tahap ketiga, berjalan dan memutar kamera dengan keyboard dan mouse)
#include "raylib.h"

int main(void)
{
    InitWindow(960, 540, "Berjalan di peta");
    SetTargetFPS(60);
    DisableCursor(); // sembunyikan kursor agar kamera bisa diputar dengan 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 untuk berjalan dan mouse untuk memutar pandangan, bekerja dengan mode 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;
}

Yang seharusnya Anda lihat: Tombol W A S D menggerakkan kamera dan mouse memutar pandangan. Kursor tersembunyi.

6. Periksa tabrakan dengan dinding

Periksa empat sudut di sekitar pemain sebelum menerima posisi baru. Jika sebuah sudut menabrak dinding, batalkan hanya sumbu tersebut sehingga pemain bisa meluncur di sepanjang dinding. Geser titik pandang dengan jumlah yang sama agar arah pandangan tidak berubah.

Kode: 04_collision.c

// Berkas: src/main.c  (tahap keempat, periksa tabrakan dinding sebelum gerakan diterima)
#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},
};

// Periksa apakah titik (x, z) di dunia berada di dalam sel dinding
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;
}

// Periksa keempat sudut di sekitar pemain. Jika ada sudut yang menabrak dinding, posisi itu dianggap tidak bisa dilalui
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, "Tabrak dinding");
    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())
    {
        // simpan posisi sebelum bergerak, lalu biarkan UpdateCamera menghitung posisi baru
        Vector3 before = camera.position;
        UpdateCamera(&camera, CAMERA_FIRST_PERSON);

        // jika posisi baru menabrak dinding, kembali hanya pada sumbu yang menabrak agar bisa meluncur di sepanjang dinding
        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;

        // ikut geser titik pandang agar arah pandangan tidak berubah
        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;
}

Yang seharusnya Anda lihat: Berjalan menabrak dinding akan menghentikan Anda tanpa menembusnya, dan Anda tetap bisa meluncur di sepanjang dinding.

Menembak dengan sinar: sinar dari tengah layar mengenai kotak musuh, dan hasil terdekat adalah targetnya
Menembak dengan sinar: sinar dari tengah layar mengenai kotak musuh, dan hasil terdekat adalah targetnya

7. Tembak dengan sinar dari tengah layar

GetMouseRay membuat sinar dari sebuah titik di layar. Contoh ini memakai tengah layar, lalu GetRayCollisionBox memeriksa apakah sinar mengenai kotak musuh.

Kode: 05_shoot.c

// Berkas: src/main.c  (tahap kelima, menembak dengan sinar dari mouse yang mengenai kotak musuh)
#include "raylib.h"

// kotak batas dari satu musuh
static BoundingBox enemyBox = {
    { 14.0f, 0.0f, 14.0f }, // sudut minimum
    { 15.5f, 2.5f, 15.5f }, // sudut maksimum
};
static bool enemyAlive = true;

int main(void)
{
    InitWindow(960, 540, "Tembak musuh");
    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);

        // klik kiri untuk menembakkan sinar dari tengah layar ke depan
        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; // kena tembak, musuh hilang
            }
        }

        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;
}

Yang seharusnya Anda lihat: Membidik kotak merah lalu klik kiri membuatnya hilang. Menembak ke arah lain tidak terjadi apa-apa.

8. HUD yang menampilkan amunisi dan kesehatan

HUD menggambar teks dan bilah dalam 2D setelah EndMode3D dipanggil. Jumlah amunisi berkurang setiap kali menembak.

Kode: 06_hud.c

// Berkas: src/main.c  (tahap keenam, HUD menampilkan amunisi dan kesehatan)
#include <stdio.h>
#include "raylib.h"

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

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

    // bilah kesehatan di pojok kiri bawah
    DrawRectangle(20, GetScreenHeight() - 40, 200, 20, DARKGRAY);
    DrawRectangle(20, GetScreenHeight() - 40, (int)(200 * p->health / 100.0f), 20, RED);

    // jumlah amunisi di pojok kanan bawah
    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--; // satu kali tembakan mengurangi amunisi
        }

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

    CloseWindow();
    return 0;
}

Yang seharusnya Anda lihat: Pojok kanan bawah menampilkan amunisi 12, dan bilah merah di pojok kiri bawah menunjukkan kesehatan penuh. Amunisi berkurang 1 pada setiap klik.

9. Satukan semuanya

Berkas ini menggabungkan peta, tabrakan dinding, dua musuh dengan kesehatan, menembak ke hasil terdekat, dan HUD.

Kode: 07_full_main.c

// Berkas: src/main.c  (gabungan semua tahap dalam satu berkas)
#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 dengan C dan 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);

            // tembak musuh terdekat yang kena sinar
            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();

        // bidikan di tengah layar
        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;
}

Yang seharusnya Anda lihat: Anda berjalan di dalam ruangan dengan dinding dan menembak musuh berwarna merah. Satu musuh membutuhkan dua tembakan untuk hilang, dan amunisi berkurang pada setiap tembakan.

10. Kompilasi dan jalankan

Gunakan perintah cl di Developer Command Prompt untuk mengompilasi dan menautkan dengan raylib.lib.

1. Buka Developer Command Prompt for VS 2022 dan masuk ke folder proyek

2. Jalankan perintah kompilasi di blok bawah

3. Jalankan fps-c-raylib.exe yang dihasilkan

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

Yang seharusnya Anda lihat: Tidak ada error saat kompilasi, dan jendela game terbuka ketika .exe dijalankan.

Langkah berikutnya

Berikutnya, tambahkan suara tembakan, musuh yang berjalan menuju pemain, menu awal, dan memuat peta dari berkas teks. Contoh lainnya ada di github.com/raysan5/raylib/tree/master/examples.

Ringkasan

Gunakan raylib sebagai mesin open-source dengan C, dan tulis sendiri peta, gerakan, tabrakan dinding, tembakan sinar, dan HUD. Kompilasi dengan MSVC menjadi satu .exe. Setiap langkah bisa diikuti secara berurutan.