C言語でシューティングゲームを作る【コンソールゲーム】

717, 2023-08-08

目次

C言語でシューティングゲームを作る【コンソールゲーム】

はい。最近の記事では暑いしか言ってない気がしますが、今日も言わせてください。
暑いです。
ちょっと異常な気象ですね。

それで今回はC言語でシューティングゲームを作りたいと思います。
コンソールゲームでリアルタイムではなく入力ごとにゲームが更新される仕組みのやつです。
そのソースコードを解説していきます。

動作風景

今回のゲームは動作させると以下のようになります。

c-shooting-game

ソースコード全文

ソースコードの全文は以下になります。

#include <stdio.h>
#include <stdlib.h>
#include <stdbool.h>
#include <string.h>
#include <math.h>

enum {
    ENEMIES_SIZE = 4,
    BULLETS_SIZE = 10,
    CANVAS_WIDTH = 60,
    CANVAS_HEIGHT = 30,
    CANVAS_SIZE = CANVAS_WIDTH * CANVAS_HEIGHT,
    PLAYER,
    ENEMY,
    BULLET,
};

typedef struct {
    int x, y;
} Vector2i;

typedef struct {
    Vector2i pos;
} Player;

typedef struct {
    Vector2i pos;
    bool is_active;
} Enemy;

typedef struct {
    Vector2i pos;
    bool is_active;
} Bullet;

Player player;
Enemy enemies[ENEMIES_SIZE];
Bullet bullets[BULLETS_SIZE];
int canvas[CANVAS_SIZE];

void init(void) {
    player.pos.x = CANVAS_WIDTH / 2;
    player.pos.y = CANVAS_HEIGHT - 1;
    int i = player.pos.y * CANVAS_WIDTH + player.pos.x;
    canvas[i] = PLAYER;
}

void draw(void) {
    system("clear");  // Windows: system("cls");

    for (int y = 0; y < CANVAS_HEIGHT; y++) {
        for (int x = 0; x < CANVAS_WIDTH; x++) {
            int i = y * CANVAS_WIDTH + x;
            int c = canvas[i];
            switch (c) {
            case PLAYER: putchar('P'); break;
            case ENEMY: putchar('E'); break;
            case BULLET: putchar('@'); break;
            default: putchar('.'); break;
            }
        }
        putchar('\n');
    }
}

void fire(void) {
    for (int i = 0; i < BULLETS_SIZE; i++) {
        Bullet *bullet = &bullets[i];
        if (!bullet->is_active) {
            bullet->is_active = true;
            bullet->pos.x = player.pos.x;
            bullet->pos.y = player.pos.y - 1;
            break;
        }
    }
}

void move_left(void) {
    if (player.pos.x > 0) {
        player.pos.x--;
    }
}

void move_right(void) {
    if (player.pos.x < CANVAS_WIDTH) {
        player.pos.x++;
    }
}

bool update_by_input(void) {
    printf("space to fire. a to move left, d to move right.\n");
    printf("command > ");
    char cmd[128];
    if (!fgets(cmd, sizeof cmd, stdin)) {
        return false;
    }

    int c = cmd[0];
    if (c == ' ') {
        fire();
    } else if (c == 'a') {
        move_left();
    } else if (c == 'd') {
        move_right();
    }

    return true;
}

void random_spawn_enemy(void) {
    if (rand() % 3 == 0) {
        for (int i = 0; i < ENEMIES_SIZE; i++) {
            Enemy *enemy = &enemies[i];
            if (!enemy->is_active) {
                enemy->is_active = true;
                enemy->pos.x = rand() % CANVAS_WIDTH;
                enemy->pos.y = 0;
                break;
            }
        }
    }
}

void update_enemies(void) {
    for (int i = 0; i < ENEMIES_SIZE; i++) {
        Enemy *enemy = &enemies[i];
        if (enemy->is_active) {
            if (rand() % 2 == 0) {
                enemy->pos.y++;
                if (enemy->pos.y >= CANVAS_HEIGHT) {
                    enemy->is_active = false;
                }
            }
        }
    }
}

void update_bullets(void) {
    for (int i = 0; i < BULLETS_SIZE; i++) {
        Bullet *bullet = &bullets[i];
        if (bullet->is_active) {
            bullet->pos.y--;
            if (bullet->pos.y < 0) {
                bullet->is_active = false;
            }
        }
    }
}

void check_collision(void) {
    for (int i = 0; i < BULLETS_SIZE; i++) {
        Bullet *bullet = &bullets[i];
        if (bullet->is_active) {
            for (int j = 0; j < ENEMIES_SIZE; j++) {
                Enemy *enemy = &enemies[j];
                if (enemy->is_active) {
                    if (bullet->pos.x == enemy->pos.x &&
                        abs(bullet->pos.y - enemy->pos.y) <= 1) {
                        bullet->is_active = enemy->is_active = false;
                        break;
                    }
                }
            }
        }
    }
}

void update_canvas(void) {
    memset(canvas, 0, sizeof canvas);

    int i = player.pos.y * CANVAS_WIDTH + player.pos.x;
    canvas[i] = PLAYER;

    for (int i = 0; i < ENEMIES_SIZE; i++) {
        Enemy *enemy = &enemies[i];
        if (enemy->is_active) {
            int j = enemy->pos.y * CANVAS_WIDTH + enemy->pos.x;
            canvas[j] = ENEMY;
        }
    }

    for (int i = 0; i < BULLETS_SIZE; i++) {
        Bullet *bullet = &bullets[i];
        if (bullet->is_active) {
            int j = bullet->pos.y * CANVAS_WIDTH + bullet->pos.x;
            canvas[j] = BULLET;
        }
    }
}

bool update(void) {
    if (!update_by_input()) {
        return false;
    }

    random_spawn_enemy();
    check_collision();
    update_bullets();
    update_enemies();
    update_canvas();

    return true;
}

int main(void) {
    init();

    for (;;) {
        draw();
        if (!update()) {
            break;
        }
    }

    return 0;
}

定数

定数は以下になります。

enum {
    ENEMIES_SIZE = 4,
    BULLETS_SIZE = 10,
    CANVAS_WIDTH = 60,
    CANVAS_HEIGHT = 30,
    CANVAS_SIZE = CANVAS_WIDTH * CANVAS_HEIGHT,
    PLAYER,
    ENEMY,
    BULLET,
};

ENEMIES_SIZEBULLETS_SIZEは配列の要素数です。
今回はintの配列にプレイヤーや敵を配置します。
この1次元配列をキャンバスと呼称します。
キャンバスの横幅がCANVAS_WIDTHで高さがCANVAS_HEIGHTです。
キャンバスの配列の要素数がCANVAS_SIZEです。

PLAYER, ENEMY, BULLETはキャンバス上のオブジェクトを表す定数です。

構造体

typedef struct {
    int x, y;
} Vector2i;

typedef struct {
    Vector2i pos;
} Player;

typedef struct {
    Vector2i pos;
    bool is_active;
} Enemy;

typedef struct {
    Vector2i pos;
    bool is_active;
} Bullet;

キャンバス上に配置されるオブジェクトは座標で管理されます。
その座標を表現する構造体がVector2iです。
これはx座標とy座標を表現します。

キャンバスは1次元配列で座標は2次元になってます。
ですので2次元の座標を1次元の配列のインデックスにするには座標変換が必要になります。

Playerはプレイヤー、Enemyは敵、Bulletは弾丸です。
EnemyBulletis_activeはオブジェクトがアクティブな時に真になります。
アクティブなオブジェクトは更新されて描画されるようになります。
非アクティブなオブジェクトは更新もされないし描画もされません。

グローバル変数

グローバル変数は以下になります。

Player player;
Enemy enemies[ENEMIES_SIZE];
Bullet bullets[BULLETS_SIZE];
int canvas[CANVAS_SIZE];

今回はシングルスレッドでゲームを作るのでオブジェクトはすべてグローバル変数にしています。
playerはプレイヤー、enemiesは敵のオブジェクト、bulletsは弾丸、canvasはキャンバスです。

main関数

main関数は以下になります。

int main(void) {
    init();

    for (;;) {
        draw();
        if (!update()) {
            break;
        }
    }

    return 0;
}

init()で初期化後に無限ループでdraw()update()を実行します。
update()falseを返して来たら無限ループから脱出します。

draw関数

draw関数は以下です。

void draw(void) {
    system("clear");  // Windows: system("cls");

    for (int y = 0; y < CANVAS_HEIGHT; y++) {
        for (int x = 0; x < CANVAS_WIDTH; x++) {
            int i = y * CANVAS_WIDTH + x;
            int c = canvas[i];
            switch (c) {
            case PLAYER: putchar('P'); break;
            case ENEMY: putchar('E'); break;
            case BULLET: putchar('@'); break;
            default: putchar('.'); break;
            }
        }
        putchar('\n');
    }
}

draw関数ではキャンバスを描画します。
キャンバスは1次元配列ですが、for文は2次元で回します。
そしてy * CANVAS_WIDTH + xで2次元のカウント変数を1次元の配列の添え字に変換します。
キャンバスの要素をswitch文で分岐して描画を行います。

x座標の要素が出力できたらputchar('\n')で改行を入れます。
改行を入れないとちゃんとゲーム画面が描画されません。

update関数

update関数は以下です。

bool update(void) {
    if (!update_by_input()) {
        return false;
    }

    random_spawn_enemy();
    check_collision();
    update_bullets();
    update_enemies();
    update_canvas();

    return true;
}

update_by_input()は入力に応じてゲームを更新します。
random_spawn_enemy()はランダムに敵キャラをスポーンします。
check_collision()は敵キャラと弾丸の衝突判定です。
update_bullets()は弾丸の更新。
update_enemies()は敵キャラの更新。
update_canvas()はオブジェクトをキャンバスに反映します。

update関数はboolを返します。
falseを返したら無限ループが終了します。

update_by_input関数

update_by_input関数は以下です。

bool update_by_input(void) {
    printf("space to fire. a to move left, d to move right.\n");
    printf("command > ");
    char cmd[128];
    if (!fgets(cmd, sizeof cmd, stdin)) {
        return false;
    }

    int c = cmd[0];
    if (c == ' ') {
        fire();
    } else if (c == 'a') {
        move_left();
    } else if (c == 'd') {
        move_right();
    }

    return true;
}

この関数はプロンプトを出し、ユーザーから一行入力されるのを待ちます。
cmdには入力された文字列が保存されます。

cmd[0]が半角スペースならfire()を呼び出します。
英字のaだったらmove_left(), 英字のbだったらmove_right()を呼び出します。

fgets()がNULLポインタを返したらこの関数はfalseを返し、無事に終了したらtrueを返します。

random_spawn_enemy関数

void random_spawn_enemy(void) {
    if (rand() % 3 == 0) {
        for (int i = 0; i < ENEMIES_SIZE; i++) {
            Enemy *enemy = &enemies[i];
            if (!enemy->is_active) {
                enemy->is_active = true;
                enemy->pos.x = rand() % CANVAS_WIDTH;
                enemy->pos.y = 0;
                break;
            }
        }
    }
}

この関数はランダムに敵をスポーン(配置)します。
rand() % 3 == 0は乱数を3で割ってその余りが0だったら真になります。

enemiesをfor文で回し、enemy->is_activeが偽なら敵のアクティブ状態と座標を初期化します。
敵キャラは上から登場しますのでy座標は常に0になります。
x座標は0からCANVAS_WIDTHの範囲でランダムにセットされます。

check_collision関数

void check_collision(void) {
    for (int i = 0; i < BULLETS_SIZE; i++) {
        Bullet *bullet = &bullets[i];
        if (bullet->is_active) {
            for (int j = 0; j < ENEMIES_SIZE; j++) {
                Enemy *enemy = &enemies[j];
                if (enemy->is_active) {
                    if (bullet->pos.x == enemy->pos.x &&
                        abs(bullet->pos.y - enemy->pos.y) <= 1) {
                        bullet->is_active = enemy->is_active = false;
                        break;
                    }
                }
            }
        }
    }
}

この関数では敵キャラと弾丸の衝突判定をします。
敵と弾丸が衝突していたら両方を非アクティブにします。

まずfor文でbulletsを回し、弾丸がアクティブならenemiesを回します。
そして弾丸と敵の座標を比較します。

    if (bullet->pos.x == enemy->pos.x &&
        abs(bullet->pos.y - enemy->pos.y) <= 1) {
        bullet->is_active = enemy->is_active = false;
        break;
    }

比較式はオブジェクトのx座標とy座標を比較します。
abs(bullet->pos.y - enemy->pos.y) <= 1という式ですが、これはy座標の差分の絶対値が1以下かどうか調べています。
仮にbullet->pos.y == enemy->pos.yという式にすると、不具合が起こります。
これは実際に変更して試してみてほしいです。弾丸と敵キャラが素通りしてしまうバグが出ます。

update_bullets関数

void update_bullets(void) {
    for (int i = 0; i < BULLETS_SIZE; i++) {
        Bullet *bullet = &bullets[i];
        if (bullet->is_active) {
            bullet->pos.y--;
            if (bullet->pos.y < 0) {
                bullet->is_active = false;
            }
        }
    }
}

この関数は弾丸を更新します。
弾丸がアクティブならy座標をキャンバスの上方向に移動させます。
弾丸のy座標が0より下になったら弾丸を非アクティブにします。

update_enemies関数

void update_enemies(void) {
    for (int i = 0; i < ENEMIES_SIZE; i++) {
        Enemy *enemy = &enemies[i];
        if (enemy->is_active) {
            if (rand() % 2 == 0) {
                enemy->pos.y++;
                if (enemy->pos.y >= CANVAS_HEIGHT) {
                    enemy->is_active = false;
                }
            }
        }
    }
}

この関数は敵を更新します。
敵はランダムに下移動してきます。上から下に向かって移動してくるわけですね。
キャンバスの外に出たら非アクティブになります。
敵がアクティブならrand() % 2 == 0の式に応じてy座標を下方向に移動させます。

update_canvas関数

void update_canvas(void) {
    memset(canvas, 0, sizeof canvas);

    int i = player.pos.y * CANVAS_WIDTH + player.pos.x;
    canvas[i] = PLAYER;

    for (int i = 0; i < ENEMIES_SIZE; i++) {
        Enemy *enemy = &enemies[i];
        if (enemy->is_active) {
            int j = enemy->pos.y * CANVAS_WIDTH + enemy->pos.x;
            canvas[j] = ENEMY;
        }
    }

    for (int i = 0; i < BULLETS_SIZE; i++) {
        Bullet *bullet = &bullets[i];
        if (bullet->is_active) {
            int j = bullet->pos.y * CANVAS_WIDTH + bullet->pos.x;
            canvas[j] = BULLET;
        }
    }
}

この関数はオブジェクトをキャンバスに反映させます。
キャンバスのプレイヤーの座標にPLAYERを、敵の座標にENEMYを、弾丸の座標にBULLETを代入します。

おわりに

今回はC言語でシューティングゲームを作ってみました。
何か参考になれば幸いです。

(^ _ ^)

弾丸をシュート!

(・ v ・)

敵を撃破!



この記事のアンケートを送信する