Nodachisoft Nodachi Sword Icon
  
@あまじ✎ 2021年5月7日に更新

第4章2 C言語コンソール上で迷路脱出プログラム

イチからゲーム作りで覚えるC言語
第4章1 コンソール・ゲーム用ライブラリ : PREV
NEXT : 第4章3 C言語コンソール上で迷路脱出プログラム・迷路の自動生成 :

概要

前回用意した、自前ゲームライブラリを使って、 RPG や シューティング、ブロック崩しゲームなどを実現するために必要となる、 リアルタイムに文字などのキャラクタを操作し、画面上に表示する ゲームの基礎的な部分を作っていきましょう!

今回は簡単な迷路を操作して、出口まで案内したらクリア!というシンプルなものにしてみます。

ライブラリを含むソースコードはココをクリックで圧縮された zip 形式でダウンロードできます。

プログラムの実行イメージ

このページで作成していく小さい迷路ゲームを動かした時のイメージです。

無事に迷路から脱出できた時のゲームプレイイメージです。

迷路からの脱出成功パターン

脱出できずにあきらめて ESC キーを押した時のゲームプレイイメージです。

迷路からの脱出失敗パターン

プログラムの流れ

設計というほど大げさなものではないですが、プログラムを起動してから 終了するまでの流れを図にして頭を整理しておきます。

ゲームのフローチャート

プログラムが main 関数からスタートして、 まず最初に、ゲームデータを準備したり、必要なメモリを確保したりします。

その後、ゲームの処理を行うためのループ(メインループ)に入ります。

メインループでは、下のような処理を行います。

  1. キーボード入力を確認 … プレイヤの操作を確認します。
  2. ゲームの状況を進行 … リアルタイムにゲームの状況を更新していきます。
  3. 画面バッファに書き込み … 画面に表示する内容を、事前にバッファに書き込みます。
  4. 画面の表示を更新 … バッファ内容を画面に表示し、更新します。

ソースコード

ゲームの初期化から終了まで、以下のソースコードで実現しています。

ゲームとしては最低限の要素のみで作られていますので、 全体で 200 行弱であり、そこまで長いコードではないかと思います。

"ndcgamelib.h" には、様々なゲームでよくつかうような機能をまとめていますので、 詳細は前ページ「コンソール・ゲーム用ライブラリ」をご参照ください。

 
main.c
#include <windows.h>
#include <stdio.h>
#include <stdlib.h>
#include "ndcgamelib.h" // 自作ライブラリ読み込み

// ==================================================
// 構造体の定義
// ==================================================
// ゲーム内で扱うオブジェクトの構造体を定義 
typedef struct {
    double posx;    // 位置 x成分
    double posy;    // 位置 y成分
    double speed;   // 移動スピード
} GameObject;

// ゲームデータをこの構造体の中で管理する
typedef struct {
    int screensize_x;   // 画面サイズ X成分
    int screensize_y;   // 画面サイズ Y成分
    GameObject player;  // プレイヤの情報
    char* map;          // マップデータ
} GameData;

// ==================================================
// 関数のプロトタイプ宣言
// ==================================================
// 画面に描画する内容を定義
void drawScreen(GameData* gamedata);
// 指定したマップ上の座標が、移動可能(壁などではない)なら TRUE が返る
BOOL isSpaceOnMap(GameData* gamedata, int posx, int posy);
// 出口にプレイヤーが十分近づいたとき、TRUE を返す
BOOL isPlayerTouchingExit(GameData* gamedata);
// キーボードからの入力を確認し、プレイヤーを移動する
void readInput(GameData* gamedata, long deltaTime);

// ==================================================
// 関数の定義
// ==================================================
// 画面バッファに描画する内容を定義
void drawScreen(GameData *gamedata) {
    clearScreenBuffer();    // 画面のバッファをすべてクリア

    // 壁をバッファに描画
    for (int y = 0; y < gamedata->screensize_y; y++) {
        for (int x = 0; x < gamedata->screensize_x; x++) {
            char dispStr[2] = { gamedata->map[y * gamedata->screensize_x + x] , 0 };
            drawText(x, y, dispStr, (RgbColor) { 255, 155, 155 });
        }
    }

    // ゲーム説明文をバッファに描画
    drawText(2, 0, "←↑↓→ キーで操作、== の出口まで辿り着いたらクリア", (RgbColor) { 155, 155, 255 });
    drawText(2, 1, "ESC キーで脱出をあきらめます", (RgbColor) { 155, 155, 255 });

    // プレイヤーをバッファに描画
    drawText(gamedata->player.posx, gamedata->player.posy, "o", (RgbColor) { 255, 255, 255 });
}

// 指定したマップ上の座標が、移動可能(壁などではない)なら TRUE が返る
BOOL isSpaceOnMap(GameData* gamedata, int posx, int posy) {
    int mapPosition = posy * gamedata->screensize_x + posx;
    char mapchip = gamedata->map[mapPosition];
    if ( mapchip == ' ' || mapchip == '=' ) {
        return TRUE;
    }
    return FALSE;
}

// 出口にプレイヤーが十分近づいたとき、TRUE を返す
BOOL isPlayerTouchingExit(GameData* gamedata) {
    int mapPosition = (int)round(gamedata->player.posy) * gamedata->screensize_x + (int)round(gamedata->player.posx);
    char mapchip = gamedata->map[mapPosition];
    if (mapchip == '=') {
        return TRUE;
    }
    return FALSE;
}

// キーボードからの入力を確認し、プレイヤーを移動する
void readInput(GameData* gamedata, long deltaTime) {
    double charSpeed = deltaTime * gamedata->player.speed / 1000.0;
    if (isKeyDown(VK_LEFT)) { // ←キーが押されている状態
        if (gamedata->player.posx - charSpeed > 0) { // 座標 x は 0 以下にならない
            int preCheckX = (int)round(gamedata->player.posx - charSpeed);
            int preCheckY = (int)round(gamedata->player.posy);
            if (isSpaceOnMap(gamedata, preCheckX, preCheckY)) {
                gamedata->player.posx -= charSpeed;
            }
        }
    }
    if (isKeyDown(VK_RIGHT)) { // →キーが押されている状態
        if (gamedata->player.posx + charSpeed < gamedata->screensize_x ) {
            int preCheckX = (int)round(gamedata->player.posx + charSpeed);
            int preCheckY = (int)round(gamedata->player.posy);
            if (isSpaceOnMap(gamedata, preCheckX, preCheckY)) {
                gamedata->player.posx += charSpeed;
            }
        }
    }
    if (isKeyDown(VK_UP)) { // ↑キーが押されている状態
        if (gamedata->player.posy - charSpeed  > 0) {
            int preCheckX = (int)round(gamedata->player.posx);
            int preCheckY = (int)round(gamedata->player.posy - charSpeed);
            if (isSpaceOnMap(gamedata, preCheckX, preCheckY)) {
                gamedata->player.posy -= charSpeed;
            }
        }
    }
    if (isKeyDown(VK_DOWN)) { // ↓キーが押されている状態
        if (gamedata->player.posy + charSpeed < gamedata->screensize_y) {
            int preCheckX = (int)round(gamedata->player.posx);
            int preCheckY = (int)round(gamedata->player.posy + charSpeed);
            if (isSpaceOnMap(gamedata, preCheckX, preCheckY)) {
                gamedata->player.posy += charSpeed;
            }
        }
    }
}

int main() {
    hideCursor();   // カーソルの点滅を隠す
    setConsoleWindowTitle("ぷらんくの迷路探索!");  // ウィンドウのタイトルバー変更

    // プレイヤが操作するキャラクタを作成
    GameObject player;
    player.posx = 5;
    player.posy = 15;
    player.speed = 12.0;

    // ゲームデータの初期化
    GameData gamedata;
    gamedata.player = player;
    gamedata.screensize_x = 60;
    gamedata.screensize_y = 21;

    // マップデータを作成する
    gamedata.map = 
        "                                                            "
        "                                                            "
        "                                                            "
        "  [][]==[][][][][][][][][][][][][][][][][][][][][][][][][]  "
        "  []        [][]                  []    []          []  []  "
        "  []        []    []  [][]  [][]  [][]      []  []      []  "
        "  []            [][]  []      []    [][]  [][]    []  [][]  "
        "  [][]  []  []        []  [][][][]              []      []  "
        "  []  [][][]      [][][]  []    []  [][][][][][][]  [][][]  "
        "  []          [][]        [][]  []    []      []        []  "
        "  [][][][][]      [][][][]      [][]      []      [][][][]  "
        "  []    []    []      []    [][][]  [][][]  [][]    []  []  "
        "  []        [][]  []  [][]  []      []      []  []  []  []  "
        "  [][][][][][][][][]            []  []  []  []      []  []  "
        "  []          []  []  [][][][]  []  []  []  []  []  []  []  "
        "  []          []  [][]      []  []  []  []  []  []  []  []  "
        "  []                    []      []      []      []      []  "
        "  [][][][][][][][][][][][][][][][][][][][][][][][][][][][]  "
        "                                                            "
        "                                                            ";

    long deltaTime;
	initNdcGameLib(gamedata.screensize_x, gamedata.screensize_y);
    BOOL isClearGame = FALSE;
    // ゲームのメインループ処理です。ESC キーを押すとループから抜けます
    while ( !isKeyPushed(VK_ESCAPE) && !isClearGame ) {
        deltaTime = getDeltaTime();      // ループで戻ってくるまでの時間(ミリ秒)
        readInput(&gamedata, deltaTime); // キー入力を確認して移動
        if (isPlayerTouchingExit(&gamedata)) {    // 出口に辿り着いたかを判定
            isClearGame = TRUE;
        }
        drawScreen(&gamedata);           // 画面に描画する内容を設定
        flushScreen();                   // 画面に描画するデータを反映
        Sleep(1);                        // CPU をこのプログラムで占有しない
    }

    // ゲームを終了し、結果表示画面へ
    clearScreenBuffer();
    if (isClearGame) {
        drawText(4, 8,  "あなたは無事にダンジョンからの脱出に成功しました。", (RgbColor) { 255, 155, 205 });
        drawText(18, 10, "- G A M E   C L E A R -", (RgbColor) { 55, 255, 25 });
    } else {
        drawText(4, 8, "ダンジョン脱出に失敗した。魔物のごはんになった。", (RgbColor) { 255, 155, 205 });
        drawText(18, 10, "- G A M E   O V E R -", (RgbColor) { 255, 55, 25 });
    }
    drawText(15, 14, "スペース キーで終了します", (RgbColor) { 205, 255, 125 });
    flushScreen();
    while (!isKeyPushed(VK_SPACE)) { // スペースキーをチェック
        Sleep(1);
    }
    freeNdcGameLib();
    fflush(stdout);
}

では、ゲームを実行する順番と合わせて内容を確認していきます。

ゲームの起動・初期化パート

main 関数からプログラムが動き出したら、 画面の初期設定や、必要なゲームデータの作成を行っています。

画面の初期設定としては、以下を実施しています。

  1. 通常、ゲームをプレイするときに、カーソルの点滅などは邪魔ですので、最初に消しておきます。
  2. ウィンドウのタイトルバーを変更します。ゲームの名前に設定しておきます。
  3. 自作ライブラリの初期化を行います。コンソールのウィンドウサイズがここで変更されます。
 
main.c
int main() {
    hideCursor();   // カーソルの点滅を隠す
    setConsoleWindowTitle("ぷらんくの迷路探索!");  // ウィンドウのタイトルバー変更

    // プレイヤが操作するキャラクタを作成
    GameObject player;
    player.posx = 5;
    player.posy = 15;
    player.speed = 12.0;

    // ゲームデータの初期化
    GameData gamedata;
    gamedata.player = player;
    gamedata.screensize_x = 60;
    gamedata.screensize_y = 21;

    // マップデータを作成する
    gamedata.map = 
        "                                                            "
        "                                                            "
        "                                                            "
        "  [][]==[][][][][][][][][][][][][][][][][][][][][][][][][]  "
        "  []        [][]                  []    []          []  []  "
        "  []        []    []  [][]  [][]  [][]      []  []      []  "
        "  []            [][]  []      []    [][]  [][]    []  [][]  "
        "  [][]  []  []        []  [][][][]              []      []  "
        "  []  [][][]      [][][]  []    []  [][][][][][][]  [][][]  "
        "  []          [][]        [][]  []    []      []        []  "
        "  [][][][][]      [][][][]      [][]      []      [][][][]  "
        "  []    []    []      []    [][][]  [][][]  [][]    []  []  "
        "  []        [][]  []  [][]  []      []      []  []  []  []  "
        "  [][][][][][][][][]            []  []  []  []      []  []  "
        "  []          []  []  [][][][]  []  []  []  []  []  []  []  "
        "  []          []  [][]      []  []  []  []  []  []  []  []  "
        "  []                    []      []      []      []      []  "
        "  [][][][][][][][][][][][][][][][][][][][][][][][][][][][]  "
        "                                                            "
        "                                                            ";

    long deltaTime;
	initNdcGameLib(gamedata.screensize_x, gamedata.screensize_y);
    BOOL isClearGame = FALSE;

また、ゲームの実行に必要なデータを設定しています。

ゲームステージに登場するプレイヤーやアイテム、敵キャラクタなどは GameObject 構造体に収めるようにします。

今回はアイテムや他のキャラクタは登場しないので、 プレイヤーの GameObject データを用意するのみです。

main.c
    // プレイヤが操作するキャラクタを作成
    GameObject player;
    player.posx = 5;
    player.posy = 15;
    player.speed = 12.0;

最初に迷路の中でプレイヤーがいる位置について、 x 座標を posx、y 座標を posy で設定しておきます。 また、キーボードでプレイヤーを操作した時の移動スピードを speed で設定しておきます。

続いて、ゲーム全体のデータは GameData 構造体の中で 管理していきます。 今回は、先ほど作成したプレイヤーのデータや ゲームに使用する画面サイズを定義しています。 今回は、画面は横幅 60 文字ぶん、縦幅 21 文字ぶんを使うことをここで定義しています。

main.c
    // ゲームデータの初期化
    GameData gamedata;
    gamedata.player = player;
    gamedata.screensize_x = 60;
    gamedata.screensize_y = 21;

続いて、迷路のマップデータを定義しています。 今回は自動生成ではなく、あらかじめ手動で作成した迷路を書き込んでいます。

main.c
    // マップデータを作成する
    gamedata.map = 
        "                                                            "
        "                                                            "
        "                                                            "
        "  [][]==[][][][][][][][][][][][][][][][][][][][][][][][][]  "
        "  []        [][]                  []    []          []  []  "
        "  []        []    []  [][]  [][]  [][]      []  []      []  "
        "  []            [][]  []      []    [][]  [][]    []  [][]  "
        "  [][]  []  []        []  [][][][]              []      []  "
        "  []  [][][]      [][][]  []    []  [][][][][][][]  [][][]  "
        "  []          [][]        [][]  []    []      []        []  "
        "  [][][][][]      [][][][]      [][]      []      [][][][]  "
        "  []    []    []      []    [][][]  [][][]  [][]    []  []  "
        "  []        [][]  []  [][]  []      []      []  []  []  []  "
        "  [][][][][][][][][]            []  []  []  []      []  []  "
        "  []          []  []  [][][][]  []  []  []  []  []  []  []  "
        "  []          []  [][]      []  []  []  []  []  []  []  []  "
        "  []                    []      []      []      []      []  "
        "  [][][][][][][][][][][][][][][][][][][][][][][][][][][][]  "
        "                                                            "
        "                                                            ";

このマップの上をプレイヤーは歩き回ることになります。 どの場所が移動できるマスなのか、どのマスがゲームクリアの出口なのかは 別途、if 文などで判定していますが、 今回のゲームでは下のルールでマップを作成しています。

  • 半角スペース はプレイヤーは通行可能
  • イコール "=" までプレイヤーが到着したら脱出
  • 壁 "[" と "]" はプレイヤーは通行不可
main.c
    long deltaTime;
	initNdcGameLib(gamedata.screensize_x, gamedata.screensize_y);
    BOOL isClearGame = FALSE;

deltaTime は、今後ゲームを進めるときの時間を計算するために使用します。

また、以下でゲームライブラリの初期化していきます。

main.c
	initNdcGameLib(gamedata.screensize_x, gamedata.screensize_y);

この関数はヘッダー "ndcgamelib.h"、"ndcgamelib.c" で実装しており、 自作ゲームライブラリを使用する一番最初に呼び出すようにしています。

ライブラリについては詳細は前ページ「コンソール・ゲーム用ライブラリ」をご参照ください。

この関数を呼び出すことで、 画面横幅 gamedata.screensizex 、画面縦幅 gamedata.screensizey に変更し、 画面表示用のバッファを初期化したり、 以降、画面の文字色をエスケープコードを使用して変更できるようにするなど、 必要な初期化処理をまとめて行ってくれます。

変数 isClearGame はゲーム終了時に、無事にクリアしたのか、 それとも脱出できずに ESC キーを押したのかを記憶しておく変数として用意しています。

この後のゲーム進行の際、無事に脱出したときに TRUE と設定する変数です。

ゲームのメインループ

続いて、脱出ゲームのメインループ処理について確認していきます。

 
main.c
    // ゲームのメインループ処理です。ESC キーを押すとループから抜けます
    while ( !isKeyPushed(VK_ESCAPE) && !isClearGame ) {
        deltaTime = getDeltaTime();      // ループで戻ってくるまでの時間(ミリ秒)
        readInput(&gamedata, deltaTime); // キー入力を確認して移動
        if (isPlayerTouchingExit(&gamedata)) {    // 出口に辿り着いたかを判定
            isClearGame = TRUE;
        }
        drawScreen(&gamedata);           // 画面に描画する内容を設定
        flushScreen();                   // 画面に描画するデータを反映
        Sleep(1);                        // CPU をこのプログラムで占有しない
    }

メインループを行う、 while 文の条件の中身を確認してみます。

  • !isKeyPushed(VK_ESCAPE) は ESC キーが押されていない時、真(TRUE)です。
  • !isClearGame はゲームクリアしていない時、真(TRUE)です。

つまり、ESC キーを押したり、ゲームクリア(isClearGame変数が True)したら 即座にメインループ処理から抜ける、という意味になります。

続いて、メインループ処理でどんなことをしているか確認していきます。

ゲーム時間の進行具合を計算

メインループ処理を行うたびに、ゲームは進行し、 キャラクタの表示位置やアニメーションなどの 時間に沿って変化していきます。

 
main.c
        deltaTime = getDeltaTime();      // ループで戻ってくるまでの時間(ミリ秒)

メインループ処理の中身が前回実行されてからの差分を 最初にミリ秒単位で、変数 deltaTime に取得することで、 時間で変化していくゲームオブジェクトやゲーム処理を計算することができます。

関数 getDeltaTime() はライブラリ "ndcgamelib" で定義されていて、 前回呼び出した時から経過したミリ秒を返す関数です。 初めて呼び出し時は 0 が返ります。

プレイヤーの入力をチェックする

ゲームプレイヤーを操作するために、readInput 関数の中で キーボードからの入力を判定しています。

 
main.c
        readInput(&gamedata, deltaTime); // キー入力を確認して移動

readInput 関数は同じソースコード中で下の用に定義されています。

キーボード操作を受け付けて、プレイヤを動かす処理、移動先に壁がないかなどの 判定を同時に行っているので、 gamedata 変数を渡します。 また、プレイヤが移動できるスピードを計算するため、deltaTime を渡します。

 
main.c
// キーボードからの入力を確認し、プレイヤーを移動する
void readInput(GameData* gamedata, long deltaTime) {
    double charSpeed = deltaTime * gamedata->player.speed / 1000.0;
    if (isKeyDown(VK_LEFT)) { // ←キーが押されている状態
        if (gamedata->player.posx - charSpeed > 0) { // 座標 x は 0 以下にならない
            int preCheckX = (int)round(gamedata->player.posx - charSpeed);
            int preCheckY = (int)round(gamedata->player.posy);
            if (isSpaceOnMap(gamedata, preCheckX, preCheckY)) {
                gamedata->player.posx -= charSpeed;
            }
        }
    }
    if (isKeyDown(VK_RIGHT)) { // →キーが押されている状態
        if (gamedata->player.posx + charSpeed < gamedata->screensize_x ) {
            int preCheckX = (int)round(gamedata->player.posx + charSpeed);
            int preCheckY = (int)round(gamedata->player.posy);
            if (isSpaceOnMap(gamedata, preCheckX, preCheckY)) {
                gamedata->player.posx += charSpeed;
            }
        }
    }
    if (isKeyDown(VK_UP)) { // ↑キーが押されている状態
        if (gamedata->player.posy - charSpeed  > 0) {
            int preCheckX = (int)round(gamedata->player.posx);
            int preCheckY = (int)round(gamedata->player.posy - charSpeed);
            if (isSpaceOnMap(gamedata, preCheckX, preCheckY)) {
                gamedata->player.posy -= charSpeed;
            }
        }
    }
    if (isKeyDown(VK_DOWN)) { // ↓キーが押されている状態
        if (gamedata->player.posy + charSpeed < gamedata->screensize_y) {
            int preCheckX = (int)round(gamedata->player.posx);
            int preCheckY = (int)round(gamedata->player.posy + charSpeed);
            if (isSpaceOnMap(gamedata, preCheckX, preCheckY)) {
                gamedata->player.posy += charSpeed;
            }
        }
    }
}

この関数の中では、まず、具体的なプレイヤの移動距離を計算しています。 前回のキャラクタの位置からの移動距離は以下で計算されます。

main.c
    double charSpeed = deltaTime * gamedata->player.speed / 1000.0;

player.speed はゲームの初期化をしたときに 12.0 という値を設定しており、

変数 charSpeed = deltaTime(前回から経過した差分ミリ秒) × 12 / 1000.0

という計算となります。 これは、具体的には 1 秒間(1000ミリ秒)で 12 マスぶんを移動できるスピードということになります。

その後、キーボードの上下左右が押されている状態をチェックしています。 ここでは、←キーが押されている時の処理についてピックアップして確認してみます。

main.c
    if (isKeyDown(VK_LEFT)) { // ←キーが押されている状態
        if (gamedata->player.posx - charSpeed > 0) { // 座標 x は 0 以下にならない
            int preCheckX = (int)round(gamedata->player.posx - charSpeed);
            int preCheckY = (int)round(gamedata->player.posy);
            if (isSpaceOnMap(gamedata, preCheckX, preCheckY)) {
                gamedata->player.posx -= charSpeed;
            }
        }
    }

isKeyDown 関数は自作ライブラリの中で定義されており、 VK_LEFT を引数に渡すことで、「←」キーが押されているかどうかのチェックを行います。

この関数が呼び出されたときに「←」キーが押されていれば TRUE が返り、IF文の中が実行されます。

main.c
    if (isKeyDown(VK_LEFT)) { // ←キーが押されている状態
        if (gamedata->player.posx - charSpeed > 0) { // 座標 x は 0 以下にならない            int preCheckX = (int)round(gamedata->player.posx - charSpeed);
            int preCheckY = (int)round(gamedata->player.posy);
            if (isSpaceOnMap(gamedata, preCheckX, preCheckY)) {
                gamedata->player.posx -= charSpeed;
            }
        }
    }

←キーが押されているとき、プレイヤーの位置を先ほど求めた charSpeed ぶん、画面左側に 移動したいのですが、もしかしたら、壁や、画面のフチにぶつかるかもしれません。

実際にプレイヤの位置を更新する前に、移動出来るかどうかを判別する必要があります。

画面のフチとのぶつかっているかの当たり判定として、プレイヤを移動したときの座標(gamedata->player.posx - charSpeed)が 0 以上であることとしています。

移動可能だった場合、次に、移動先が「壁」かどうかを識別します。

main.c
    if (isKeyDown(VK_LEFT)) { // ←キーが押されている状態
        if (gamedata->player.posx - charSpeed > 0) { // 座標 x は 0 以下にならない
            int preCheckX = (int)round(gamedata->player.posx - charSpeed);            int preCheckY = (int)round(gamedata->player.posy);            if (isSpaceOnMap(gamedata, preCheckX, preCheckY)) {                gamedata->player.posx -= charSpeed;            }        }
    }

プレイヤが移動した場合に、画面上のどの座標になるかを計算します。 このとき、座標は整数で扱いますので、 round 関数を使って、小数点をまるめます。

プレイヤの移動先の座標(preCheckX, preCheckY)を計算したら、 isSpaceMap 関数で、座標(preCheckX, preCheckY)が移動可能かをチェックします。

もし結果が TRUE で移動可能なら、プレイヤの位置を以下のように更新し、左に移動します。

main.c
                gamedata->player.posx -= charSpeed;

指定したマップの座標が移動可能か

指摘したマップの座標が移動可能かの判別を isSpaceMap 関数で行っています。

main.c
// 指定したマップ上の座標が、移動可能(壁などではない)なら TRUE が返る
BOOL isSpaceOnMap(GameData* gamedata, int posx, int posy) {
    int mapPosition = posy * gamedata->screensize_x + posx;
    char mapchip = gamedata->map[mapPosition];
    if ( mapchip == ' ' || mapchip == '=' ) {
        return TRUE;
    }
    return FALSE;
}

マップの中で座標(posx, posy)がどのような値かを識別するためには、 座標(posx, posy)が gamedata->map の char 型配列の、どの添え字に 合致するかを計算する必要があります。

gamedata->map の char 型配列は、 横幅 gamedata->screensizex(ゲームの画面幅)、縦幅 gamedata->screensizey の データを格納しており、 座標(posx, posy)の添え字 mapPosition は以下のように計算できます。

main.c
int mapPosition = posy * gamedata->screensize_x + posx;

続いて、指定した座標が移動可能かの判別は以下のように行っています。

main.c
// 指定したマップ上の座標が、移動可能(壁などではない)なら TRUE が返る
BOOL isSpaceOnMap(GameData* gamedata, int posx, int posy) {
    int mapPosition = posy * gamedata->screensize_x + posx;
    char mapchip = gamedata->map[mapPosition];    if ( mapchip == ' ' || mapchip == '=' ) {        return TRUE;    }    return FALSE;}

座標(posx, posy) のマップデータが ' '(半角スペース)、もしくは '='(イコール記号) だったら、TRUE を返し、それ以外なら FALSE を返しています。

ゲームを進行する

メインループの処理で、キーボード入力を行った後を確認していきます。

isPlayerTouchingExit 関数では、gamedata に含まれるプレイヤーの座標の位置が "="(脱出のマス)かどうかを識別し、もしプレイヤーが "=" と重なっていた場合は TRUE を返しています。結果、 isClearGame 変数は TRUE となり、メインループ処理から抜け出します。

 
main.c
    // ゲームのメインループ処理です。ESC キーを押すとループから抜けます
    while ( !isKeyPushed(VK_ESCAPE) && !isClearGame ) {
        deltaTime = getDeltaTime();      // ループで戻ってくるまでの時間(ミリ秒)
        readInput(&gamedata, deltaTime); // キー入力を確認して移動
        if (isPlayerTouchingExit(&gamedata)) {    // 出口に辿り着いたかを判定            isClearGame = TRUE;        }        drawScreen(&gamedata);           // 画面に描画する内容を設定
        flushScreen();                   // 画面に描画するデータを反映
        Sleep(1);                        // CPU をこのプログラムで占有しない
    }

画面へ描画する

画面に描画する処理は、すべて drawScreen 関数の中に記載して整理しています。 drawScreen の中では、データの中身を描画する処理に集中し、 ゲームのデータを更新するような処理は書かないように注意します。

このようにデータを進行させる処理と、ゲームの内容を画面に描画する処理を 分けて処理を記載する事で、あとからバグが発生したときの 記載がおかしな場所を推測しやすくなったりし、プログラム完成までの労力を減らすことができます。

 
main.c
    // ゲームのメインループ処理です。ESC キーを押すとループから抜けます
    while ( !isKeyPushed(VK_ESCAPE) && !isClearGame ) {
        deltaTime = getDeltaTime();      // ループで戻ってくるまでの時間(ミリ秒)
        readInput(&gamedata, deltaTime); // キー入力を確認して移動
        if (isPlayerTouchingExit(&gamedata)) {    // 出口に辿り着いたかを判定
            isClearGame = TRUE;
        }
        drawScreen(&gamedata);           // 画面に描画する内容を設定        flushScreen();                   // 画面に描画するデータを反映
        Sleep(1);                        // CPU をこのプログラムで占有しない
    }

それでは drawScreen 関数の中身を確認していきます。

 
main.c
// 画面バッファに描画する内容を定義
void drawScreen(GameData *gamedata) {
    clearScreenBuffer();    // 画面のバッファをすべてクリア

    // 壁をバッファに描画
    for (int y = 0; y < gamedata->screensize_y; y++) {
        for (int x = 0; x < gamedata->screensize_x; x++) {
            char dispStr[2] = { gamedata->map[y * gamedata->screensize_x + x] , 0 };
            drawText(x, y, dispStr, (RgbColor) { 255, 155, 155 });
        }
    }

    // ゲーム説明文をバッファに描画
    drawText(2, 0, "←↑↓→ キーで操作、== の出口まで辿り着いたらクリア", (RgbColor) { 155, 155, 255 });
    drawText(2, 1, "ESC キーで脱出をあきらめます", (RgbColor) { 155, 155, 255 });

    // プレイヤーをバッファに描画
    drawText(gamedata->player.posx, gamedata->player.posy, "o", (RgbColor) { 255, 255, 255 });
}

drawScreen 関数では、画面に描画する内容を決めますが、 実際に画面には表示せず、ゲームライブラリ(ndcgamelib)の中にある、バッファに表示する内容を書き込みます。

これは、物理的にディスプレイに表示して目に見えるようにする処理は時間がかかるため、 事前に必要なデータは全てバッファに書き込みをしておいて、バッファの中身を一気にディスプレイに出力したほうが早いからです。

まずは、バッファの中身を初期化する関数を呼び出しています。

main.c
    clearScreenBuffer();    // 画面のバッファをすべてクリア

clearScreenBuffer 関数は "ndcgamelib.h" をインクルードすると使える関数で、 ゲームライブラリ(ndcgamelib)の中で管理している画面描画のバッファを初期化します。

バッファを初期化すると、いままでバッファに書き込んでいた内容はテキストや背景色はすべて削除されます。

続いて、迷路の壁をバッファに書き込んでいきます。

 
main.c
    // 壁をバッファに描画
    for (int y = 0; y < gamedata->screensize_y; y++) {
        for (int x = 0; x < gamedata->screensize_x; x++) {
            char dispStr[2] = { gamedata->map[y * gamedata->screensize_x + x] , 0 };
            drawText(x, y, dispStr, (RgbColor) { 255, 155, 155 });
        }
    }

画面上の全ての座標 (x,y) について、マップデータを確認して、 内容を drawText 関数を使ってゲームライブラリ(ndcgamelib)のバッファに書き込む、という処理を行っています。

ゲームライブラリ(ndcgamelib)のバッファに書き込むためには専用の関数 drawText を使用します。

drawText 関数は下のように呼び出します。

drawText関数の仕様
drawText( 表示する開始 x座標 ,表示する開始 y座標
         ,表示する文字列
         ,文字の色 );

文字の色は RgbColor 構造体という構造体でデータを渡して色を指定することができます。

例として下のように呼び出します。

赤色を指定する例
RgbColor exColor;
exColor.r = 255;
exColor.g = 0;
exColor.b = 0;
drawText( 0, 0, "Hello World!", exColor);   // 赤色でHello World!

壁を描画したのち、プレイヤーがゲームを操作できるように 最低限のメッセージをバッファに書き込んでおきます。

 
main.c
    // ゲーム説明文をバッファに描画
    drawText(2, 0, "←↑↓→ キーで操作、== の出口まで辿り着いたらクリア", (RgbColor) { 155, 155, 255 });
    drawText(2, 1, "ESC キーで脱出をあきらめます", (RgbColor) { 155, 155, 255 });

    // プレイヤーをバッファに描画
    drawText(gamedata->player.posx, gamedata->player.posy, "o", (RgbColor) { 255, 255, 255 });

今回は、十字キーの操作方法と、ESC キーについて画面の上の方に操作方法を書いておきました。

また、プレイヤーを "o" 記号で表現して、描画しておきます。

画面への出力・表示の更新

実際にディプレイへバッファの内容を表示するための処理は メインループ処理の中の flushScreen 関数で行っています。

 
main.c
    // ゲームのメインループ処理です。ESC キーを押すとループから抜けます
    while ( !isKeyPushed(VK_ESCAPE) && !isClearGame ) {
        deltaTime = getDeltaTime();      // ループで戻ってくるまでの時間(ミリ秒)
        readInput(&gamedata, deltaTime); // キー入力を確認して移動
        if (isPlayerTouchingExit(&gamedata)) {    // 出口に辿り着いたかを判定
            isClearGame = TRUE;
        }
        drawScreen(&gamedata);           // 画面に描画する内容を設定
        flushScreen();                   // 画面に描画するデータを反映        Sleep(1);                        // CPU をこのプログラムで占有しない
    }

ライブラリ(ndcgamelib)の中で管理されているバッファを画面に出力します。 ここは出力する画面の性能によって、時間がかかります。 例えば 60FPS のディスプレイであれば、毎秒 60 回の画面更新できる性能をもってますので、 ざっくり 1 回の画面更新には 0.0166秒かかることになります。

0.0166 秒は人間の感覚では一瞬のように感じますが、計算プログラムをただ処理していくのに比べるととても遅く、 必要ないタイミングで画面更新を頻繁にしてしまうと、ゲーム全体がとても遅くなってしまう、なんていう可能性があります。

また、ある時間のゲーム内容を画面に表示したいとき、 書き換えのバッファ内容を画面に出力してしまうと、人間の目からみて画面がちらつく、ということが発生する可能性があります。 このようなことを防ぐためにも、画面のデータを処理して、その断面の状態を一度バッファに保存し、 バッファの内容を一度に画面に出力(物理的に画面上の表示を更新する)というルールとすると良いかと思います。

CPUの占有をしない

メインループ処理の最後に、Sleep 関数を入れることで、 OS はこのプログラムだけではなく、他に起動しているプログラム(例えばメモ帳だったり、ブラウザだったり) の処理を並行して CPU で処理することができます。

 
main.c
    // ゲームのメインループ処理です。ESC キーを押すとループから抜けます
    while ( !isKeyPushed(VK_ESCAPE) && !isClearGame ) {
        deltaTime = getDeltaTime();      // ループで戻ってくるまでの時間(ミリ秒)
        readInput(&gamedata, deltaTime); // キー入力を確認して移動
        if (isPlayerTouchingExit(&gamedata)) {    // 出口に辿り着いたかを判定
            isClearGame = TRUE;
        }
        drawScreen(&gamedata);           // 画面に描画する内容を設定
        flushScreen();                   // 画面に描画するデータを反映
        Sleep(1);                        // CPU をこのプログラムで占有しない    }

長い間実行し続けるループ処理を行う時は、他のプログラムの処理が中断してしまわないように、 Sleep 関数を適度に入れておくことがマナーです。

エンディングの処理

メインループ処理から抜け出した後は エンディングのための処理を行います。

今回はシンプルに「無事に脱出できたか」「脱出をあきらめたか」のフラグをもとに、 エンディング画面の出し分けをしています。

 
main.c
    // ゲームを終了し、結果表示画面へ
    clearScreenBuffer();
    if (isClearGame) {
        drawText(4, 8,  "あなたは無事にダンジョンからの脱出に成功しました。", (RgbColor) { 255, 155, 205 });
        drawText(18, 10, "- G A M E   C L E A R -", (RgbColor) { 55, 255, 25 });
    } else {
        drawText(4, 8, "ダンジョン脱出に失敗した。魔物のごはんになった。", (RgbColor) { 255, 155, 205 });
        drawText(18, 10, "- G A M E   O V E R -", (RgbColor) { 255, 55, 25 });
    }
    drawText(15, 14, "スペース キーで終了します", (RgbColor) { 205, 255, 125 });
    flushScreen();
    while (!isKeyPushed(VK_SPACE)) { // スペースキーをチェック
        Sleep(1);
    }
    freeNdcGameLib();
    fflush(stdout);

最後に freeNdcGameLib() 関数で、ゲームライブラリ(ndcgamelib) で使っていたメモリを解放しておきます。 プログラムが終了すると、自動的にメモリは解放されるので、やっておかなくても影響はないと思いますが、ここでは念のため。

補足

最低限のゲーム要素をもったプログラムの流れを確認していきました。 アイテム要素や時間制限、迷路の自動生成や敵キャラクターなど、 さまざまな要素を付け加えていけば、よりゲームらしさが出てくるかと思います。

今回は C 言語をつかったゲームプログラミングがテーマですので、 シンプルな文字を組み合わせた画面のみで、綺麗な画像やアニメーションなどのやり方は 記載していません。 ですが、例えば他の言語を使用したり、ゲームエンジンを使ったゲーム開発でも 根本では似たようなプログラミングの考え方が随所に使われていると思いますので、少しでもお役に立てばと思います。

参考文献

イチからゲーム作りで覚えるC言語
第4章1 コンソール・ゲーム用ライブラリ : PREV
NEXT : 第4章3 C言語コンソール上で迷路脱出プログラム・迷路の自動生成 :
 
 
送信しました!

コメント、ありがとうございます。

なんかエラーでした

ごめんなさい。エラーでうまく送信できませんでした。ご迷惑をおかけします。しばらくおいてから再度送信を試していただくか、以下から DM などでご連絡頂ければと思います。

Twitter:@NodachiSoft_jp
お名前:
 
連絡先:
 
メッセージ:
 
戻る
内容の確認!

以下の内容でコメントを送信します。よろしければ、「送信」を押してください。修正する場合は「戻る」を押してください

お名前:
 
連絡先:
 
メッセージ:
 
Roboto からの操作ではないという確認のため確認キーを入れてください。
確認キー=95
戻る
 / 
送信確認へ
コメント欄
コメント送信確認へ

関連ありそうな記事(5件)です!

第4章3 C言語コンソール上で迷路脱出プログラム・迷路の自動生成

#C11仕様#C言語#ゲームプログラミング✎ 2021-05-15
C言語のコンソールゲームで迷路脱出ゲームプログラムの作り方を確認します。迷路は自動生成されます
広告領域
追従 広告領域
目次
第4章2 C言語コンソール上で迷路脱出プログラム
第4章2 C言語コンソール上で迷路脱出プログラム
概要
概要
プログラムの実行イメージ
プログラムの実行イメージ
プログラムの流れ
プログラムの流れ
ソースコード
ソースコード
ゲームの起動・初期化パート
ゲームの起動・初期化パート
ゲームのメインループ
ゲームのメインループ
ゲーム時間の進行具合を計算
ゲーム時間の進行具合を計算
プレイヤーの入力をチェックする
プレイヤーの入力をチェックする
指定したマップの座標が移動可能か
指定したマップの座標が移動可能か
ゲームを進行する
ゲームを進行する
画面へ描画する
画面へ描画する
画面への出力・表示の更新
画面への出力・表示の更新
CPUの占有をしない
CPUの占有をしない
エンディングの処理
エンディングの処理
補足
補足
参考文献
参考文献
Nodachisoft © 2021