6日でできる 新C言語入門|C言語でシューティングゲームを作る

学んだC言語の知識を、動くゲームへ。
変数、配列、構造体、関数、ループを組み合わせて、最後の総合演習としてシューティングゲームを完成させましょう。

「6日でできる 新C言語入門」で学んできた内容の締めくくりとして、今回はC言語でシューティングゲームを作っていきます。

これまでに学んだ変数、配列、条件分岐、繰り返し、関数、構造体、乱数、文字配列などは、単体で見るとそれぞれ別の知識に見えるかもしれません。けれども、ゲームという形にまとめてみると、それぞれがしっかり役割を持ってつながっていることが分かります。

シューティングゲームは、画面にプレイヤーを表示し、敵を出現させ、弾を撃ち、当たり判定を行い、スコアやライフを管理するという流れでできています。つまり、C言語の基本文法を総合的に使える、とてもよい演習題材です。

今回は、特別なグラフィックライブラリを使わず、コンソール画面で動くシンプルなシューティングゲームを作ります。文字を使ってプレイヤーや敵、弾を表現するので、C言語の基本だけで十分に実装できます。

この記事では、まずゲームの設計を考え、そのあとで実際のプログラムを確認しながら、どの部分でどの文法や考え方が使われているのかを丁寧に見ていきます。
最終的には、自分で改造したくなるような、学びの多い1本の作品として仕上げていきましょう。

今回作るシューティングゲームの内容

今回作るゲームは、次のようなルールにします。

項目内容
プレイヤー画面下にいる自機
画面上から順番に出現して下へ進む
攻撃プレイヤーは弾を撃てる
操作aで左移動、dで右移動、fで発射、sで待機、qで終了
ライフ敵が下まで到達するとライフが減る
スコア敵を弾で倒すと増える
クリア条件一定スコアに到達する
ゲームオーバーライフが0になる

コンソールゲームなので、リアルタイムに激しく動くものではなく、1ターンごとに入力して進める形にします。
この方式なら、標準入力と標準出力だけでしっかりゲームらしい動きを作れますし、内部処理も追いやすくなります。

どんなC言語の知識を使うのか

このゲームでは、これまで学んできたC言語の知識がたくさん登場します。

学習項目ゲーム内での使い道
変数スコア、ライフ、ターン数の管理
配列弾や敵を複数まとめて管理
構造体プレイヤーや敵の位置情報をまとめる
関数描画、入力、移動、当たり判定などを分割
if文画面外チェック、命中判定、終了判定
switch文入力された操作ごとの処理分岐
for文弾や敵を順番に処理
while文ゲーム全体の進行
乱数敵の出現位置や出現タイミング
文字配列画面の表示を作る

このように、1本のゲームを作るだけで、かなり多くの要素を自然に復習できます。
総合演習としてとても相性のよいテーマです。

まずはゲームの設計を考える

プログラムを書き始める前に、どんなデータが必要かを整理しておくと、後の実装がかなり楽になります。

今回のゲームでは、次のような情報が必要です。

データ内容
プレイヤーの位置自機が今どこにいるか
弾の位置発射された弾がどこにあるか
敵の位置出現した敵がどこにいるか
スコア何体倒したか
ライフ何回まで敵の侵入を許せるか
ターン数何ターン目か

ここで大切になるのが、位置情報をまとめて扱う考え方です。

弾も敵も、必要なのは主に x座標、y座標、そして現在有効かどうかです。
このようなまとまりを構造体で表すと、とても管理しやすくなります。

たとえば、弾や敵は次のような考え方で扱えます。

  • x : 横方向の位置
  • y : 縦方向の位置
  • active : 画面内で有効かどうか

これを構造体にしておけば、弾も敵も同じような形で管理できます。

図:シューティングゲームの画面構成と主要データ

この図から分かること

この図から分かるのは、ゲーム画面に見えているものは、内部では座標や配列のデータとして管理されているということです。

プレイヤー、弾、敵は見た目には記号として表示されますが、プログラムの中では x座標、y座標、active のような情報で扱われています。
ゲームを作るときは、見た目を直接操作するのではなく、まずデータを更新し、その結果を画面に描き直すという考え方が基本になります。

処理を関数に分ける考え方

ゲームを1つの長い main関数だけで書いてしまうと、すぐに読みづらくなってしまいます。
そこで、処理ごとに関数を分けて考えます。

今回のゲームでは、たとえば次のように分けると分かりやすいです。

関数名役割
initGameゲーム全体の初期化
initObjects弾や敵の配列を初期化
drawGame画面を描画する
handleInputプレイヤーの入力を処理する
fireBullet弾を発射する
moveBullets弾を上へ進める
moveEnemies敵を下へ進める
spawnEnemy新しい敵を出現させる
checkCollisions弾と敵の当たり判定を行う
isGameClearクリア判定
isGameOverゲームオーバー判定

関数に分けることで、1つ1つの役割がはっきりし、修正や追加もやりやすくなります。
たとえば、敵の動きを変えたければ moveEnemies を見ればよく、弾の発射ルールを変えたければ fireBullet を見ればよい、という状態になります。

ゲームの流れを先に整理しておく

ゲーム全体の進行を、先に文章で整理しておくことも大切です。
今回のゲームは次の順番で動きます。

  1. ゲームを初期化する
  2. 画面を表示する
  3. プレイヤーの入力を受け取る
  4. 弾を動かす
  5. 敵を動かす
  6. 必要に応じて新しい敵を出現させる
  7. 弾と敵の当たり判定を行う
  8. スコアやライフを更新する
  9. クリアまたはゲームオーバーなら終了する
  10. そうでなければ次のターンへ進む

この流れをそのままプログラムにしていけば、ゲームとして自然な構成になります。

シューティングゲームのサンプルプログラム

それでは、実際に動くシューティングゲームのサンプルプログラムを見てみましょう。
今回は1ファイルで完成する形にしています。

プロジェクト/ファイル名: Lesson74/main.c

#include <stdio.h>
#include <stdlib.h>
#include <time.h>

#define WIDTH 20
#define HEIGHT 10
#define MAX_BULLETS 10
#define MAX_ENEMIES 10
#define TARGET_SCORE 10
#define INITIAL_LIVES 3

typedef struct {
    int x;
    int y;
    int active;
} Object;

typedef struct {
    int x;
    int lives;
    int score;
    int turn;
} Player;

void initObjects(Object objects[], int size);
void initGame(Player* player, Object bullets[], Object enemies[]);
void drawGame(const Player* player, const Object bullets[], const Object enemies[]);
void handleInput(Player* player, Object bullets[]);
void fireBullet(const Player* player, Object bullets[]);
void moveBullets(Object bullets[]);
void moveEnemies(Object enemies[], Player* player);
void spawnEnemy(Object enemies[]);
void checkCollisions(Object bullets[], Object enemies[], Player* player);
int isGameClear(const Player* player);
int isGameOver(const Player* player);

int main(void) {
    Player player;
    Object bullets[MAX_BULLETS];
    Object enemies[MAX_ENEMIES];

    srand((unsigned)time(NULL));

    initGame(&player, bullets, enemies);

    printf("=== C言語で作るシューティングゲーム ===\n");

    while (!isGameClear(&player) && !isGameOver(&player)) {
        drawGame(&player, bullets, enemies);
        handleInput(&player, bullets);
        moveBullets(bullets);
        moveEnemies(enemies, &player);
        spawnEnemy(enemies);
        checkCollisions(bullets, enemies, &player);
        player.turn++;
    }

    drawGame(&player, bullets, enemies);

    if (isGameClear(&player)) {
        printf("ゲームクリアです。おめでとうございます。\n");
    } else {
        printf("ゲームオーバーです。\n");
    }

    return 0;
}

void initObjects(Object objects[], int size) {
    int i;

    for (i = 0; i < size; i++) {
        objects[i].x = 0;
        objects[i].y = 0;
        objects[i].active = 0;
    }
}

void initGame(Player* player, Object bullets[], Object enemies[]) {
    player->x = WIDTH / 2;
    player->lives = INITIAL_LIVES;
    player->score = 0;
    player->turn = 1;

    initObjects(bullets, MAX_BULLETS);
    initObjects(enemies, MAX_ENEMIES);
}

void drawGame(const Player* player, const Object bullets[], const Object enemies[]) {
    char field[HEIGHT][WIDTH];
    int x, y;
    int i;

    for (y = 0; y < HEIGHT; y++) {
        for (x = 0; x < WIDTH; x++) {
            field[y][x] = ' ';
        }
    }

    for (i = 0; i < MAX_ENEMIES; i++) {
        if (enemies[i].active) {
            field[enemies[i].y][enemies[i].x] = 'V';
        }
    }

    for (i = 0; i < MAX_BULLETS; i++) {
        if (bullets[i].active) {
            field[bullets[i].y][bullets[i].x] = '|';
        }
    }

    field[HEIGHT - 1][player->x] = 'A';

    printf("\nターン:%d  スコア:%d/%d  ライフ:%d\n",
        player->turn, player->score, TARGET_SCORE, player->lives);
    printf("+--------------------+\n");

    for (y = 0; y < HEIGHT; y++) {
        printf("|");
        for (x = 0; x < WIDTH; x++) {
            printf("%c", field[y][x]);
        }
        printf("|\n");
    }

    printf("+--------------------+\n");
    printf("操作: a=左  d=右  f=発射  s=待機  q=終了\n");
}

void handleInput(Player* player, Object bullets[]) {
    char cmd;

    printf("入力してください: ");
    scanf(" %c", &cmd);

    switch (cmd) {
        case 'a':
            if (player->x > 0) {
                player->x--;
            }
            break;

        case 'd':
            if (player->x < WIDTH - 1) {
                player->x++;
            }
            break;

        case 'f':
            fireBullet(player, bullets);
            break;

        case 's':
            break;

        case 'q':
            player->lives = 0;
            break;

        default:
            printf("不正な入力です。今回は待機します。\n");
            break;
    }
}

void fireBullet(const Player* player, Object bullets[]) {
    int i;

    for (i = 0; i < MAX_BULLETS; i++) {
        if (!bullets[i].active) {
            bullets[i].active = 1;
            bullets[i].x = player->x;
            bullets[i].y = HEIGHT - 2;
            return;
        }
    }

    printf("これ以上弾を出せません。\n");
}

void moveBullets(Object bullets[]) {
    int i;

    for (i = 0; i < MAX_BULLETS; i++) {
        if (bullets[i].active) {
            bullets[i].y--;

            if (bullets[i].y < 0) {
                bullets[i].active = 0;
            }
        }
    }
}

void moveEnemies(Object enemies[], Player* player) {
    int i;

    for (i = 0; i < MAX_ENEMIES; i++) {
        if (enemies[i].active) {
            enemies[i].y++;

            if (enemies[i].y >= HEIGHT - 1) {
                enemies[i].active = 0;
                player->lives--;
                printf("敵が防衛ラインを突破しました。\n");
            }
        }
    }
}

void spawnEnemy(Object enemies[]) {
    int i;

    if (rand() % 100 < 45) {
        for (i = 0; i < MAX_ENEMIES; i++) {
            if (!enemies[i].active) {
                enemies[i].active = 1;
                enemies[i].x = rand() % WIDTH;
                enemies[i].y = 0;
                return;
            }
        }
    }
}

void checkCollisions(Object bullets[], Object enemies[], Player* player) {
    int i, j;

    for (i = 0; i < MAX_BULLETS; i++) {
        if (!bullets[i].active) {
            continue;
        }

        for (j = 0; j < MAX_ENEMIES; j++) {
            if (!enemies[j].active) {
                continue;
            }

            if (bullets[i].x == enemies[j].x && bullets[i].y == enemies[j].y) {
                bullets[i].active = 0;
                enemies[j].active = 0;
                player->score++;
                printf("敵を撃破しました。\n");
                break;
            }
        }
    }
}

int isGameClear(const Player* player) {
    return player->score >= TARGET_SCORE;
}

int isGameOver(const Player* player) {
    return player->lives <= 0;
}

実行のイメージ

このプログラムを実行すると、毎ターンごとにコンソール画面にフィールドが表示されます。
プレイヤーは画面下に A として表示され、敵は V、弾は | で表現されます。

たとえば、次のようなイメージです。

ターン:3  スコア:2/10  ライフ:3
+--------------------+
|        V           |
|                    |
|            V       |
|          |         |
|                    |
|                    |
|                    |
|                    |
|                    |
|         A          |
+--------------------+
操作: a=左  d=右  f=発射  s=待機  q=終了
入力してください:

この表示だけでも、ちゃんとシューティングゲームらしい雰囲気が出ます。
特別な画像やウィンドウがなくても、C言語の基本だけでゲームらしい作品を作れるのは面白いところです。

プログラムの中心になる構造体

今回のプログラムでは、Object構造体とPlayer構造体を使っています。

Object構造体

typedef struct {
    int x;
    int y;
    int active;
} Object;

この構造体は、弾と敵を共通の形で扱うためのものです。

メンバ意味
x横方向の位置
y縦方向の位置
active現在使われているかどうか

弾も敵も、位置を持っていて、表示中かどうかを管理する必要があります。
そのため、同じ構造体で十分に表現できます。

Player構造体

typedef struct {
    int x;
    int lives;
    int score;
    int turn;
} Player;

プレイヤーについては、横位置、残りライフ、スコア、ターン数をまとめています。

このように、関連する情報を構造体でひとまとめにすると、変数が散らばらず、かなり見通しがよくなります。

配列で弾と敵を複数管理する

シューティングゲームでは、弾も敵も1つでは足りません。
そこで、配列を使って複数個をまとめて管理します。

Object bullets[MAX_BULLETS];
Object enemies[MAX_ENEMIES];

弾も敵も、それぞれ配列の中に入っています。
active が 1 の要素だけが、現在画面内で有効なものとして扱われます。

この考え方はゲーム作りでとてもよく使われます。
配列を使えば、複数の同種オブジェクトを簡単に一括処理できます。

たとえば、弾を全部動かしたいなら、for文で bullets 配列を順番に見ればよいわけです。

for (i = 0; i < MAX_BULLETS; i++) {
    if (bullets[i].active) {
        bullets[i].y--;
    }
}

これが、配列と繰り返し処理の組み合わせのよい練習になります。

drawGame関数で画面を作る

コンソールゲームでは、画面表示も自分で作る必要があります。
今回の drawGame関数では、まず 2次元の文字配列を用意しています。

char field[HEIGHT][WIDTH];

この field が、画面そのものの下書きになります。
最初に全部空白で埋めてから、敵、弾、プレイヤーを順番に配置しています。

処理の流れとしては、次のようになっています。

  1. フィールドを空白で初期化する
  2. 敵の位置に V を置く
  3. 弾の位置に | を置く
  4. プレイヤーの位置に A を置く
  5. 枠と一緒に全部表示する

この考え方はとても大切です。
いきなり画面へバラバラに出力するのではなく、まず内部配列に画面情報を作り、そのあとまとめて表示することで、整理された表示ができます。

図:1ターンの処理の流れ

この図から分かること

この図から分かるのは、ゲームの進行が1ターン単位の決まった順序で組み立てられているということです。

画面を表示し、入力を受け取り、オブジェクトを動かし、当たり判定をして、最後に終了条件を調べる。この流れが while文の中で繰り返されることで、ゲーム全体が動いています。
ゲーム制作では、この処理順序を最初に整理しておくことがとても重要です。

handleInput関数で操作を受け取る

プレイヤー操作は handleInput関数で行っています。

scanf(" %c", &cmd);

ここでは、1文字を入力してもらっています。
入力できるのは次の5種類です。

入力内容
a左へ移動
d右へ移動
f弾を発射
s何もしない
qゲーム終了

switch文を使うことで、入力ごとの処理が分かりやすく整理されています。

switch (cmd) {
    case 'a':
        ...
    case 'd':
        ...
    case 'f':
        ...
}

このようなメニュー入力処理は、今後ほかのプログラムを作るときにも役立ちます。

fireBullet関数で弾を発射する

弾を発射するときは、弾の配列の中から使っていない要素を探します。

if (!bullets[i].active) {
    bullets[i].active = 1;
    bullets[i].x = player->x;
    bullets[i].y = HEIGHT - 2;
    return;
}

active が 0 の場所が空きスロットです。
そこへ新しい弾の位置を入れて active を 1 にすることで、弾が出現したことになります。

ここでのポイントは、配列の空き要素を探して使うという考え方です。
これはゲーム以外でも、データ管理の基本としてよく出てきます。

moveBullets関数と moveEnemies関数の役割

弾は上へ進み、敵は下へ進みます。
そのため、それぞれの関数では y座標を変化させています。

弾の移動

bullets[i].y--;

画面の上に向かうので、y が小さくなります。
画面外へ出たら active を 0 にして無効化します。

敵の移動

enemies[i].y++;

画面の下へ進むので、y が大きくなります。
もし最下段まで来たら、プレイヤーのライフを減らします。

if (enemies[i].y >= HEIGHT - 1) {
    enemies[i].active = 0;
    player->lives--;
}

このように、位置の更新と画面外チェックをセットで行うのがポイントです。

spawnEnemy関数で新しい敵を出す

敵は毎ターン必ず出るわけではなく、乱数で確率的に出現させています。

if (rand() % 100 < 45)

この条件なら、だいたい45パーセントくらいの確率で敵が1体出ます。

出現位置もランダムです。

enemies[i].x = rand() % WIDTH;
enemies[i].y = 0;

この乱数のおかげで、毎回少しずつ違う展開になります。
乱数はゲーム作りにとても相性がよく、出現位置や行動パターンに変化を与えるのに役立ちます。

checkCollisions関数で当たり判定を行う

シューティングゲームらしさの中心になるのが、弾と敵の当たり判定です。

今回のプログラムでは、弾と敵の座標が同じなら命中したと判定しています。

if (bullets[i].x == enemies[j].x && bullets[i].y == enemies[j].y) {
    bullets[i].active = 0;
    enemies[j].active = 0;
    player->score++;
}

この処理で、弾と敵の両方を消し、スコアを1増やしています。

当たり判定自体はシンプルですが、ゲームではとても重要な考え方です。
今回は 1マス単位の衝突判定ですが、将来的により複雑なゲームを作るときも、基本は「位置を比較して判定する」というところから始まります。

クリア判定とゲームオーバー判定

ゲームの終了条件は2つあります。

  • スコアが目標に達したらクリア
  • ライフが0以下ならゲームオーバー

これを関数に分けているのが次の部分です。

int isGameClear(const Player* player) {
    return player->score >= TARGET_SCORE;
}

int isGameOver(const Player* player) {
    return player->lives <= 0;
}

終了条件を関数にしておくと、main関数の while文が読みやすくなります。

while (!isGameClear(&player) && !isGameOver(&player)) {
    ...
}

条件式そのものを長く書くよりも、何を判定しているのかがすぐ分かる形になります。

このゲームで特に大事な学習ポイント

このシューティングゲームを通して、特に意識したいポイントを整理しておきます。

ポイント内容
データの整理構造体で関連情報をまとめる
複数管理配列で弾や敵を扱う
処理の分割関数ごとに役割を分ける
画面更新2次元配列で表示内容を作る
繰り返し処理for文と while文を使い分ける
判定処理if文で命中や終了条件を判定する
ランダム性randでゲームに変化を持たせる

これらは、単にゲーム作りに役立つだけではありません。
C言語で実用的なプログラムを書くときにも、非常に重要な考え方です。

図:関数・配列・構造体のつながり

この図から分かること

この図から分かるのは、ゲームプログラムが main関数を中心に、複数の関数とデータが協力して動いているということです。

main関数はゲーム全体の流れを管理し、各関数はそれぞれの専門的な役割を持っています。
また、関数は Player構造体や bullets配列、enemies配列を操作することで、ゲームの状態を更新しています。これが、見通しのよいプログラム設計の基本です。

改造しながら理解を深めるのもおすすめ

このプログラムは、そのままでも十分にゲームとして動きますが、ここから少しずつ改造していくと学習効果がさらに高まります。

たとえば、次のような改良が考えられます。

改良アイデア学べること
敵の種類を増やす構造体の拡張
弾の速度を変える更新処理の工夫
ボス敵を登場させる条件分岐の強化
プレイヤーのライフ回復を追加するゲームルール設計
2連射や3連射を作る配列操作の応用
敵の横移動を追加する座標制御の発展
ステージ制にする関数分割と状態管理
得点に応じて難しくする条件分岐と乱数調整

たとえば、敵が1マスずつ下がるだけでなく、左右にも動くようにすると、ぐっとゲームらしくなります。
また、弾を1発だけでなく左右に広がるようにすれば、少し派手なシューティングにもできます。

最後の総合演習として見てほしいこと

このシューティングゲームは、C言語の基礎を一通り学んだあとに取り組む総合演習としてとても良い題材です。

大切なのは、1つ1つの命令を覚えることだけではありません。
それらをどう組み合わせて、1本の動くプログラムにするかが本当の力になります。

この作品の中には、次のような学習成果が詰まっています。

  • 変数で状態を持つ
  • 配列で複数データを扱う
  • 構造体で情報をまとめる
  • 関数で役割を分割する
  • if文で条件判定する
  • switch文で入力を分ける
  • for文で配列を処理する
  • while文でゲームを継続する
  • 乱数で変化をつける
  • 標準入出力で操作と表示を実現する

このように見ていくと、シューティングゲームは、C言語で学んだことを実感として結びつけるのにぴったりです。

まずはこのサンプルをそのまま入力して動かしてみてください。
そのあとで、自分なりに敵の数を増やしたり、クリア条件を変えたり、表示記号を工夫したりしてみると、プログラムを自分の手で育てている感覚が得られます。

学習の最後を、ただ終わるだけではなく、1本の作品として形に残せるのはとても大きな経験です。
ぜひこのシューティングゲームを、自分のC言語学習の集大成として完成させてみてください。