技術文章手を動かしてさくさく理解する C/C++ コンソールアプリ入門 > ドットイートゲーム


 

 

ドットイートゲーム
Nobuhide Tsuda
May-2014

概要

ドットイートゲームは自機を操り、画面上のドットをすべて消す(食べる)アクションゲームだ。

オリジナルは、セガが1979年発表の「ヘッドオン」だが、翌年ナムコが発表した「パックマン」が世界的に大ヒットし、 (特にUSでは)バリエーションも多く作られている。
参照:ドットイートゲーム

本稿では、下図のような「ヘッドオン」と同じようなゲームソースの解説を行う。

ソースコード

遊び方

解説

ダブルバッファ

本ゲームは動きが多く、単純に画面書き換えを行うと、画面がチラツキ見るに耐えなかったので、 「ダブルバッファ」を実装し、それを利用することにした。

「ダブルバッファ」はここで別途解説してるので、それを参照されたい。

準備

2次元座標

本プログラムでは、車やマップの (x, y) 2次元座標を表すために Vec2 型を導入している。

typedef std::pair<int, int> Vec2;    // (x, y) ベクトル

構造体を定義するのではなく、std::pair で簡易的に型定義を行っている。

2次元座標ベクトルは、通常演算子で比較、加算、減算ができると、コードが分かりやすく記述できて便利である。
演算子オーバーロードを用いて上記演算子を以下のように定義している。

bool operator==(const Vec2 &a, const Vec2 &b)
{
    return a.first == b.first && a.second == b.second;
}
Vec2 operator+(const Vec2 &a, const Vec2 &b)
{
    return Vec2(a.first + b.first, a.second + b.second);
}
Vec2 operator-(const Vec2 &a, const Vec2 &b)
{
    return Vec2(a.first - b.first, a.second - b.second);
}

また、四隅での方向転換処理のために、左右に90度回転する関数も以下のように定義している。

// x'  (cosθ - sinθ)  x
// y'  (sinθ + cosθ) y
Vec2 rot_right90(const Vec2 &v)
{
    return Vec2(-v.second, v.first);
}
Vec2 rot_left90(const Vec2 &v)
{
    return Vec2(v.second, -v.first);
}

演習問題:

  1. (12, 34) を値としてもつ2次元座標ベクトル Vec2 v を宣言しなさい。
  2. Vec2 v1(1,2), v2(3, 4); を宣言し、v1 + v2 を計算するコードを書き、デバッガで実行し正しく計算されていることを確認しなさい。
  3. 引数 Vec2 を2つ取り、それらが等しくないかどうかを返す bool operator!=(const Vec2 &, const Vec2 &) を定義しなさい。
  4. 引数に、Vec2、int を取り、それらを乗じた値を返す Vec2 operator*(const Vec2 &a, int b) を定義しなさい。
  5. 180度回転した値を返す Vec2 rot_180(const Vec2 v) を定義しなさい。

車構造体

自機、敵機を表す構造体を以下のように定義している。

struct Car
{
public:
    Car(int x = 0, int y = 0, int dx = 0, int dy = 0, int lane = 0)
        : m_pos(x, y), m_v(dx, dy)
        , m_lane(lane)
        , m_laneChanged(false)
        {}
public:
    Vec2 m_pos;     //  位置
    Vec2 m_v;        //  速度
    int    m_lane;     // 一番外側が0
    bool  m_laneChanged;    //  レーンチェンジした直後かどうか
};

各データを指定したコンストラクタとコピーコンストラクタを定義。

データメンバには、車の位置、速度ベクトル、レーン番号、レーンチェンジしたかどうかのフラグを用意した。
位置に速度を足し込んだものが次の位置となる。
レーン番号は、車が今どのレーンにいるかを示すもの。一番外側が0で、一番内側が4だ。 敵機は自機のレーン番号との差異を見て、レーン変更を行う。自機・敵機の位置座標からレーンを毎回計算してもいいのだが、 コードが複雑になるので、情報として保持しておくことにした。
自機は最大2レーン移動することができるが、敵機は1レーンしか移動できない仕様とした。 それを実現するために、レーン移動済みフラグを導入し、これが true であればレーン移動処理を行わないこととした。

定数

#define     CONS_WD     80
#define     CONS_HT      25
#define     MAP_WD       (CONS_WD/2)
#define     MAP_HT        (CONS_HT-1)
#define     SCORE_X      11*2
#define     SCORE_Y      11
#define     LOOP_INTERVAL    10

グローバル変数

intg_score;// スコア
Car   g_car;       // 自機
intg_nEnemy;// 敵機数
std::vector<Car> g_enemy;      // 敵機
char g_map[MAP_HT][MAP_WD];//  マップ
DblBuffer   g_db;    // ちらつき防止表示用ダブルバッファ

マップ初期化

const char *mapData[] = {
    "/--------------------------------------#",
    "| . . . . . . . . .  . . . . . . . . . |",
    "|./----------------  ----------------#.|",
    "| | . . . . . . . .  . . . . . . . . | |",
    "|.|./--------------  --------------#.|.|",
    "| | | . . . . . . .  . . . . . . . | | |",
    "|.|.|./------------  ------------#.|.|.|",
    "| | | | . . . . . .  . . . . . . | | | |",
    "|.|.|.|./----------  ----------#.|.|.|.|",
    "| | | | | . . . . .  . . . . . | | | | |",
    "|.|.|.|.|./------------------#.|.|.|.|.|",
    "|         |                  |         |",
    "|         |                  |         |",
    "|.|.|.|.|.L------------------J.|.|.|.|.|",
    "| | | | | . . . . .  . . . . . | | | | |",
    "|.|.|.|.L----------  ----------J.|.|.|.|",
    "| | | | . . . . . .  . . . . . . | | | |",
    "|.|.|.L------------  ------------J.|.|.|",
    "| | | . . . . . . .  . . . . . . . | | |",
    "|.|.L--------------  --------------J.|.|",
    "| | . . . . . . . .  . . . . . . . . | |",
    "|.L----------------  ----------------J.|",
    "| . . . . . . . . .  . . . . . . . . . |",
    "L--------------------------------------J",
    0,
};
void init_map()
{
    for (int y = 0; mapData[y] != 0; ++y) {
        for (int x = 0; x < MAP_WD; ++x) {
            g_map[y][x] = mapData[y][x];
        }
    }
}

上記はマップデータ初期値と、それを g_map[][] に設定する関数。
マップの各場所の値は char 1文字で表している。このデータを適当に変えれば、違うマップにすることも可能だ。

敵機の追加

void add_enemy()
{
    g_enemy.clear();
    g_enemy.push_back(Car(MAP_WD / 2 + 1, 1, 1, 0));
    if( g_nEnemy == 1 ) return;
    g_enemy.push_back(Car(MAP_WD / 2 + 1, MAP_HT - 2, -1, 0));
    if( g_nEnemy == 2 ) return;
    g_enemy.push_back(Car(9, MAP_HT / 2 + 1, -1, 0, /*lane:*/4));
}

上記は敵機を生成し、動的配列 g_enemy に追加する関数。
敵機数 g_nEnemy を参照し、その個数だけ生成・追加している。
敵機の位置は、上部、下部、左部とし、速度ベクトルは走る方向に正しく設定している。

1ゲームごとの初期化処理

void init()
{
    g_car = Car(MAP_WD / 2 - 1, 1, -1, 0);
    add_enemy();
    init_map();
}

上記は1ゲームごとの初期化関数。
自機・敵機を生成し、マップを初期化している。

描画関数

マップ描画

int draw_map()
{
    int nDot = 0;
    for (int y = 0; y < MAP_HT; ++y) {
        g_db.setCursorPos(0, y);
        const char *ptr = g_map[y];
        for (int x = 0; x < MAP_WD; ++x) {
            switch (*ptr++) {
                case '-':
                    g_db.setColor(DblBuffer::GRAY, DblBuffer::BLACK);
                    g_db.write("━");
                    break;
                case '|':
                    g_db.setColor(DblBuffer::GRAY, DblBuffer::BLACK);
                    g_db.write("┃");
                    break;
                case '/':
                    g_db.setColor(DblBuffer::GRAY, DblBuffer::BLACK);
                    g_db.write("┏");
                    break;
                case '#':
                    g_db.setColor(DblBuffer::GRAY, DblBuffer::BLACK);
                    g_db.write("┓");
                    break;
                case 'L':
                    g_db.setColor(DblBuffer::GRAY, DblBuffer::BLACK);
                    g_db.write("┗");
                    break;
                case 'J':
                    g_db.setColor(DblBuffer::GRAY, DblBuffer::BLACK);
                    g_db.write("┛");
                    break;
                case '.':
                    g_db.setColor(DblBuffer::YELLOW, DblBuffer::BLACK);
                    g_db.write("・");
                    ++nDot;
                    break;
                default:
                    g_db.setColor(DblBuffer::BLACK, DblBuffer::BLACK);
                    g_db.write("  ");
                    break;
            }
        }
    }
    return nDot;
}

自機描画

void draw_car()
{
    g_db.setCursorPos(g_car.m_x*2, g_car.m_y);
    g_db.setColor(DblBuffer::GREEN, DblBuffer::BLACK);
    g_db.write("@");
}

敵機描画

void draw_enemy()
{
    g_db.setColor(DblBuffer::RED, DblBuffer::BLACK);
    for (int i = 0; i < (int)g_enemy.size(); ++i) {
        g_db.setCursorPos(g_enemy[i].m_x*2, g_enemy[i].m_y);
        g_db.write("◆");
    }
}

スコア描画

std::string to_string(int v)
{
    if( !v ) return std::string("0");
    std::string str;
    while( v ) {
        str = std::string(1, '0' + v % 10) + str;
        v /= 10;
    }
    return str;
}
void draw_score()
{
    g_db.setColor(DblBuffer::GRAY, DblBuffer::BLACK);
    g_db.setCursorPos(SCORE_X, SCORE_Y);
    g_db.write("SCORE:");
    std::string str = to_string(g_score);
    while( str.size() < 6 )
        str = "0" + str;
    g_db.setCursorPos(SCORE_X, SCORE_Y + 1);
    g_db.write(str);
}

演習問題:

  1. これまでのコードに以下のコードを追加し、ビルド・実行してみなさい。
  2. int main()
    {
        g_nEnemy = 1; //  敵機数:1
        init();
        draw_map();
        draw_car();
        draw_enemy();
        draw_score();
        g_db.swap();     //  表示バッファ切り替え
        getchar();
        return 0;
    }
    
  3. 敵機の数を 2 または 3 に増やしてみなさい。
  4. 現状のコードは敵機数3までだが、もっと増やせるようにコードを修正してみなさい。

自機・敵機の移動

敵機の移動

自機・敵機移動処理の前に、移動先に移動可能かどうかをチェックする関数を定義する。

// 指定位置に壁が無いかどうかをチェック
bool can_move_to(int x, int y)
{
    return g_map[y][x] == ' ' || g_map[y][x] == '.';
}

下記は、車を移動する関数。

void move_car(Car &car)
{
    int x = car.m_x + car.m_dx;
    int y = car.m_y + car.m_dy;
    if( !can_move_to(x, y) ) {
        if( car.m_dy == 0 ) {     //  水平方向に移動している場合
            if( can_move_to(car.m_x, car.m_y + 1) )
                car.m_dy = 1;
            else
                car.m_dy = -1;
            car.m_dx = 0;
        } else {         // 垂直方向に移動している場合
            if( can_move_to(car.m_x + 1, car.m_y) )
                car.m_dx = 1;
            else
                car.m_dx = -1;
            car.m_dy = 0;
        }
        x = car.m_x + car.m_dx;
        y = car.m_y + car.m_dy;
    }
    car.m_x = x;
    car.m_y = y;
}

下記の関数は、敵機を自機のレーン方向にレーンチェンジする関数。

// 敵機を自機の方にレーン変更
//     一番外側が レーン0,一番内側が レーン4
void change_lane(Car &car)
{
    if( car.m_laneChanged ||     // 2回連続レーンチェンジ不可
        car.m_lane == g_car.m_lane ||       // 同一レーンの場合
        !can_move_to(car.m_x + car.m_dx, car.m_y + car.m_dy) )       //  行き止まりの場合
    {
        car.m_laneChanged = false;
        return;
    }
    if( car.m_lane > g_car.m_lane ) {    // 外側のレーンに移動
        if( car.m_dy == 0 ) {     //  水平方向に移動している場合
            if( car.m_y < MAP_HT/2 ) {
                if( can_move_to(car.m_x, car.m_y-1) ) {
                    car.m_y -= 2;
                    --car.m_lane;
                    car.m_laneChanged = true;
                }
            } else {
                if( can_move_to(car.m_x, car.m_y+1) ) {
                    car.m_y += 2;
                    --car.m_lane;
                    car.m_laneChanged = true;
                }
            }
        } else {
        }
    } else {     // 内側のレーンに移動
        if( car.m_dy == 0 ) {     //  水平方向に移動している場合
            if( car.m_y < MAP_HT/2 ) {
                if( can_move_to(car.m_x, car.m_y+1) ) {
                    car.m_y += 2;
                    ++car.m_lane;
                    car.m_laneChanged = true;
                }
            } else {
                if( can_move_to(car.m_x, car.m_y-1) ) {
                    car.m_y -= 2;
                    ++car.m_lane;
                    car.m_laneChanged = true;
                }
            }
        } else {
        }
    }
    assert(car.m_lane >= 0 && car.m_lane < 5);
}

m_laneChanged フラグを参照し、レーンチェンジ直後であればレーンチェンジを行わないようにしている。
自機のレーンと同じ場合、行き止まり

水平方向に移動しているときだけレーンチェンジ可能にしている。

            for (int i = 0; i < (int)g_enemy.size(); ++i) {
                move_car(g_enemy[i]);
                change_lane(g_enemy[i]);
            }

1ゲームの処理

bool game()
{
    g_score = 0;
    g_nEnemy = 1;
    init();
    int key = 0;   // 押下されたキー
    int keyDown = 0;   // 押下状態のキー
    bool update = true;    //  再描画フラグ
    int iv = 10;        // 処理インターバル
    for (int cnt=1;;++cnt) {
        if( update ) {
            int nDot = draw_map();
            draw_enemy();
            draw_car();
            draw_score();
            g_db.swap();
            if( !nDot ) {       //  ドット全消去
                mciSendString(TEXT("play one23.mp3"), NULL, 0, NULL);
                Sleep(1000);
                g_score += g_nEnemy * 1000;
                ++g_nEnemy;
                init();
                continue;
            }
            if( check_crash() ) {
                mciSendString(TEXT("play s-burst01.mp3"), NULL, 0, NULL);
                return false;
            }
        }
        Sleep(10);
        update = false;
        if( !keyDown ) {     // キー押下を受け付けていない場合
            if( isKeyPressed(VK_LEFT) ) {
                key = keyDown = VK_LEFT;
            } else if( isKeyPressed(VK_RIGHT) ) {
                key = keyDown = VK_RIGHT;
            } else if( isKeyPressed(VK_UP) ) {
                key = keyDown = VK_UP;
            } else if( isKeyPressed(VK_DOWN) ) {
                key = keyDown = VK_DOWN;
            }
        } else {
            if( !isKeyPressed(keyDown) )// 押されたキーが離された
                keyDown = 0;
        }
        if( cnt % 10 == 0 ) {
            for (int i = 0; i < (int)g_enemy.size(); ++i) {
                move_car(g_enemy[i]);
                change_lane(g_enemy[i]);
            }
            update = true;
        }
        accel_decel(key, iv);
        if( cnt % iv == 0 ) {
            move_car(g_car, key);
            eat_dot();
            update = true;
            iv = 10;
        }
    }
}

まとめ

本プログラムで使ったテクニック