読みやすいゲームプログラムを書くための 7つの基本テクニック

この記事では、可読性が高く機能拡張しやすい(保守しやすい)ゲームプログラムを書くための 7つの基本的なテクニックを紹介します。

読みやすいゲームプログラムコードを書くための7つの基本テクニック

今回紹介する方法は以下のものです。

  • 1. ローカル変数を使う
  • 2. 長い条件式を関数にする
  • 3. 長いswitch-case文を関数化する
  • 4. テーブルで処理をシンプルにする
  • 5. 配列へのアクセスは関数を経由する
  • 6. インデントは深くしない
  • 7. スコープの大きさに適した名前をつける

1. ローカル変数を使う

以下はマウスでクリックした場所に対して、プレイヤーを移動させるようなコードです。

    // 1マスあたりのマップサイズ.
    const int MAP_SIZE = 32;
    // マップデータ.
    int map[12][12] = {};
    // プレイヤーの座標.
    Vec2 playerPos = {};

    // マウスカーソルの座標を取得する.
    Vec2 mouse = getMousePos();
    if(map[mouse.x/MAP_SIZE][mouse.y/MAP_SIZE] == 0) {
        // マウスカーソルの位置に対応するマップの位置に
        // 何もなければプレイヤーをそこまで移動させる.
        playerPos.x = mouse.x/MAP_SIZE;
        playerPos.y = mouse.y/MAP_SIZE;
        // プレイヤーがいるフラグを立てておく.
        map[mouse.x/MAP_SIZE][mouse.y/MAP_SIZE] = 1;
    }

気になる部分は、マウス座標に対応するマップの位置の計算を複数記述している部分です。(「mouse.x/MAP_SIZE」「mouse.y/MAP_SIZE」という計算が複数書かれています)

これをローカル変数として定義することでコードの圧迫感を減らすことができます。

    // 1マスあたりのマップサイズ.
    const int MAP_SIZE = 32;
    // マップデータ.
    int map[12][12] = {};
    // プレイヤーの座標.
    Vec2 playerPos = {};

    // マウスカーソルの座標を取得する.
    Vec2 mouse = getMousePos();
    // 計算結果をローカル変数に入れておく.
    int mx = mouse.x/MAP_SIZE;
    int my = mouse.y/MAP_SIZE;
    if(map[mx][my] == 0) {
        // マウスカーソルの位置に対応するマップの位置に
        // 何もなければプレイヤーをそこまで移動させる.
        playerPos.x = mx;
        playerPos.y = my;
        // プレイヤーがいるフラグを立てておく.
        map[mx][my] = 1;
    }

コードの行数は増えますが、コードの横幅を少し減らすことができます。

計算式をローカル変数に置き換えるメリット
  1. コードの横幅が減って圧迫感がなくなる
  2. 計算式に修正があったときに変更箇所が1箇所で済む

2. 長い条件式を関数化する

以下はオブジェクトが画面内に存在するかどうかを判定するコードです。

    // 画面サイズ.
    Size screen = getScreenSize();
    // 現在座標.
    Vec2 pos = getPlayerPos();
    // オブジェクトの大きさ.
    float size = 16.f;

    // 画面外に出たかどうか.
    if(pos.x < -size
            || pos.y < -size
            || pos.x > screen.x + size
            || pos.y > screen.y + size) {
        // 画面外に出た.
    }

if文の条件式が「||」演算子で連結された長い条件式となっています。

長い条件式は、別途 bool を返す関数にすると読みやすいコードとなります。

// 画面外に出たかどうか.
bool isOutSide(Vec2 pos, float size) {
    // 画面サイズ.
    Size screen = getScreenSize();
    
    if(pos.x < -size) {
        return true; // 画面外(左).
    }
    if(pos.y < -size) {
        return true; // 画面外(上).
    }
    if(pos.x > screen.x + size) {
        return true; // 画面外(右).
    }
    if(pos.y > screen.y + size) {
        return true; // 画面外(下).
    }
    // 画面内.
    return false;
}

呼び出し側はかなり短いコードとなります。

    // 現在座標.
    Vec2 pos = getPlayerPos();
    // オブジェクトの大きさ.
    float size = 16.f;

    // 画面外に出たかどうか.
    if(isOutSide(pos, size)) {
        // 画面外に出た.
    }

この修正もコードの行数は増えてしまいますが、処理が明確になって理解しやすく条件式に変更があった場合に修正がやりやすくなります。

条件式を関数化するメリット
  1. 条件式が関数に置き換えられることで、何をしているのかがわかりやすくなる
  2. 条件を変更したいときに修正がやりやすくなる

3. 長いswitch-case文を関数化する

switch-case文で状態に合わせた処理を行う場合、処理を細かく関数化してコードが長くならないようにしておきます。

// 状態.
enum eState {
    eState_FadeIn, // フェードイン.
    eState_Main, // メインゲーム.
    eState_Gameover, // ゲームオーバー.
    eState_FadeOut, // フェードアウト.
    eState_End, // 終了.
};

// 状態.
eState m_State = {};

// 更新処理.
void Update() {
    switch(m_State) {
    case eState_FadeIn:   { _UpdateFadeIn();   break; }
    case eState_Main:     { _UpdateMain();     break; }
    case eState_Gameover: { _UpdateGameover(); break; }
    case eState_FadeOut:  { _UpdateFadeOut();  break; }
    case eState_End:      { _UpdateEnd();      break; }
    }
}
switch-case文の処理を関数化するメリット
  1. 状態ごとの処理がわかりやすくなり、処理の修正がやりやすくなる
  2. 状態の追加がやりやすくなる

また switch-case文に限らず、1つの関数の処理が長くなる場合には細かく関数を定義(サブルーチン化)した方が関数の見通しが良くなります。

// 更新処理.
void Update() {
    // ゲームの処理.
    _UpdateGame();

    // カメラの更新.
    _UpdateCamera();

    // UIの更新.
    _UpdateUI();

    // BGMの更新.
    _UpdateBGM();
}

4. テーブルで処理をシンプルにする

テーブルを使うと条件分岐をシンプルに書くことができます。

例えば先程の switch-case文は以下のように関数テーブルで実装できます。

// 状態.
enum eState {
    eState_FadeIn, // フェードイン.
    eState_Main, // メインゲーム.
    eState_Gameover, // ゲームオーバー.
    eState_FadeOut, // フェードアウト.
    eState_End, // 終了.

    eState_Max,
};

// 状態.
eState m_State = {};

// 関数の型を定義.
using UPDATE_FUNC = std::function<void()>;

// 配列のサイズを取得するマクロ.
#define ARRAY_SIZE_OF(a) (sizeof(a)/sizeof(a[0]))

// 更新処理.
void Update() {
    // 更新関数テーブル.
    // 状態が追加されたらここに関数を追加するだけで良い.
    UPDATE_FUNC tbl[] = {
        _UpdateFadeIn,
        _UpdateMain,
        _UpdateGameover,
        _UpdateFadeOut,
        _UpdateEnd,
    };
    // 静的に配列サイズをチェック.
    static_assert(ARRAY_SIZE_OF(tbl) == eState_Max, "error: invalid tbl size");

    // 関数テーブルから呼び出す.
    tbl[m_State]();
}

switch-case文がなくなり、代わりに tbl という配列が追加されました。switch-case文よりも少しシンプルになっています。この書き方は追加があったときに配列に追加するだけでよくなるので、とてもシンプルな書き方となります。

なおC++の場合は配列に関数ポインタを登録できるのでこのような書き方となっています。別の言語の場合はラムダ式などで登録することになるかもしれません。

ただし、この書き方は配列の特定のインデックスへ直接アクセスする危険性や、呼び出し関数がコードを見ただけではやや分かりづらいというデメリットもあるため、やや注意が必要です。

他のテーブルの使用例としては、例えば落ちものパズルで上下左右のタイルを調べるようなケースです。

    Point2 pos(5, 4); // 基準の座標.

    // 上下左右のテーブルを作る.
    Point2 tbl[] = {
        Point2(-1, 0), // 左.
        Point2(0, -1), // 上.
        Point2(1, 0), // 右.
        Point2(0, 1), // 下.
    };

    // 上下左右のタイルを調べる.
    for(auto v : tbl) { // C++のイテレーター.
        // 基準座標に足し込む.
        Point2 p = pos + v;
        // pが上下左右の位置となるので
        // その位置に対応するチェック処理を行う.
        cout << "(" << p.x << "," << p.y << ")" << endl; 
    }

落ち物パズルでのタイル座標系における「上下左右」は連続性がない値なので、このようにテーブルを定義してイテレーターを回すことで条件分岐を排除できます。

テーブル化することのメリット
  1. 条件分岐を排除することができる
  2. テーブルに追加するだけで新しい機能を追加できる

5. 配列へのアクセスは関数を経由する

特にC/C++言語の場合は、配列の領域外アクセスはメモリを壊す可能性があるので、Get〜() / Set〜() といった関数を用意して安全に配列にアクセスするようにします。

const int MAP_WIDTH = 12; // マップの幅.
const int MAP_HEIGHT = 8; // マップの高さ.

// マップデータ.
int m_Map[MAP_HEIGHT][MAP_WIDTH] = {};

// 有効なマップ座標かどうか.
bool CheckValidMapPos(int x, int y) {
    if(x < 0 || MAP_WIDTH <= x) {
        return false; // 領域外アクセス.
    }
    if(y < 0 || MAP_HEIGHT <= y) {
        return false; // 領域外アクセス.
    }
    return true; // 正しい場所.
}
// マップの値を取得する.
int GetMapData(int x, int y) {
    if(CheckValidMapPos(x, y) == false) {
        return -1; // 領域外アクセス.
    }
    return m_Map[y][x];
}
// マップに値を設定する.
void SetMapData(int x, int y, int v) {
    if(CheckValidMapPos(x, y) == false) {
        return; // 領域外アクセス。ASSERTで止めても良いかも.
    }
    m_Map[y][x] = v;
}

マップデータは幅 “MAP_WIDTH”高さ “MAP_HEIGHT” の配列として確保されているので、GetMapData() / SetMapData() では CheckValidMapPos() で範囲チェックを行ってから処理を行うようにしています。

これにより配列で確保したメモリ領域を超えた部分へのアクセスができないようになっています。

C#などでは領域外アクセスはエラーとして検知されるため、こういったチェック処理は必要ないようにも思えますが、領域外をエラーではなく何もしないようにしたい、といった場合には関数を経由してアクセスすると呼び出しがシンプルにかけることがあります。

配列へのアクセスを関数化するメリット
  1. メモリを壊す心配がなくなり安全に配列にアクセスできる
  2. 領域外アクセスをエラーにするか無視するかを自由に決められる

6. インデントは深くしない

ゲームプログラムを書くときには勢いが大事だったりするので、勢いでインデントが深いコードを書いてしまいがちです。

// オブジェクトリスト.
Object m_ObjList[8] = {};

...
    // チェックしたい情報.
    int id = 5; // IDが5であるもの.
    float px = 100; // X座標が100以上.
    float py = 500; // Y座標が500以下.

    for(auto obj : m_ObjList) {
        if(obj.m_Exists) {
            // 存在している.
            if(obj.m_Id == id) {
                // IDが一致.
                if(obj.m_PosX >= px) {
                    // 座標が100以上.
                    if(obj.m_PosY <= py) {
                        // 座標が500以下.
                        // 見つかった.
                        cout << "match!" << endl;
                    }
                }
            }
        }
    }

実装できるまではこの書き方でも良いのですが、ある程度、実装のメドが立ったらこのインデントの深さを直していきます。

方法としては、多くのパターンでは「ガード節を使う」「関数化する」の2つです。(場合によってはローカル変数にしたりテーブル化するテクニックも使えます)。

1. ガード節を使う

例となるコードでは、条件が「」となる場合にインデントが1つずつ深くなっています。これを逆に考えて「条件が『』だったら continueする」という処理に置き換えます。

するとインデントの深さが増えずに読みやすいコードとなります。

// オブジェクトリスト.
Object m_ObjList[8] = {};

...
    // チェックしたい情報.
    int id = 5; // IDが5であるもの.
    float px = 100; // X座標が100以上.
    float py = 500; // Y座標が500以下.

    for(auto obj : m_ObjList) {
        if(obj.m_Exists == false) {
            continue; // ガード節.
        }

        // 存在している.
        if(obj.m_Id != id) {
            continue; // ガード節.
        }

        // IDが一致.
        if(obj.m_PosX < px) {
            continue; // ガード節.
        }

        // 座標が100以上.
        if(obj.m_PosY > py) {
            continue; // ガード節.
        }

        // 座標が500以下.
        // 見つかった.
        cout << "match!" << endl;
    }

2. 関数化する

そもそも for文の中で多くの条件分岐を入れていることが問題と考えると、チェック用関数を作って判定するのも良いと思います。

// オブジェクトリスト.
Object m_ObjList[8] = {};

// オブジェクトが指定の条件にマッチしたかどうか.
bool isMatchObj(Object& obj, int id, float px, float py) {
    if(obj.m_Exists == false) {
        return false; // 存在していないのでチェック不要.
    }

    if(obj.m_Id != id) {
        return false; // IDが一致していない.
    }

    // IDが一致.
    if(obj.m_PosX < px) {
        return false; // X座標が指定の値よりも小さい.
    }

    // 座標が100以上.
    if(obj.m_PosY > py) {
        return false; // Y座標が指定の値よりも大きい.
    }

    // 条件にマッチした.
    return true;
}

...
    // チェックしたい情報.
    int id = 5; // IDが5であるもの.
    float px = 100; // X座標が100以上.
    float py = 500; // Y座標が500以下.

    for(auto obj : m_ObjList) {
        if(isMatchObj(obj, id, px, py) == false) {
            continue; // ガード節.
        }

        // 見つかった.
        cout << "match!" << endl;
    }
ネストを深くしない方法を使うメリット
  1. 条件に対応する処理がわかりやすくなる
  2. 新しく条件を追加するのがやりやすくなる

7. スコープの大きさに適した名前をつける

これは中規模以上のゲームを作るときの話かもしれませんが、スコープの大きさに合わせて適切な変数名・関数名をつけるようにします。

例えばターン制のコマンドバトルRPGを作る場合、戦闘中のキャラクターを管理するクラス名を”Character“というクラス名にしてしまうと フィールド探索中のキャラクターなのかバトル時のキャラクターであるかがわからなくなります。

そのため、例えばフィールド探索中のキャラクター(NPCなど)であれば “FieldCharacterバトル中のキャラクターであれば “BattleCharacter といったように適切な接頭語をつけることで区分できるようにしておきます。

もちろんキャラクターがバトル中にしか登場しないのであれば、”Character” というクラス名でも問題ありませんし、名前空間で分けることができるなら短い名前でも問題ないと思います。

さらにスコープが限定的であれば、1文字変数もありです。

// 所持金を加算.
void AddMoney(int v) {
	m_Money += v
}

引数の変数名を “money” にしても良いですが、ここでの加算値はお金しかありえないので “v” (value) といった短い変数名の方が読みやすいケースがあります。

その他細かいテクニック

公開するメンバ変数や関数は限定的にする

クラス内の変数を直接外部から変更できてしまうと、どこで書き換わっているのかがわかりにくくなります。

プログラム言語にもよりますが、private宣言が使えるならそれを定義してアクセス可能な変数や関数を限定的にすると良いです。

typo(スペルミス)を避ける

例えば “Enemy” を “Enmey” と typo(スペルミス) すると、ワード検索した時に見つからないという問題が発生します。(クラス名であればコンパイルエラーで見つかりますが、変数名の宣言だとエラーになりません)

変数名・関数名の宣言には typo の罠が潜んでいる…ということに注意すると良いです。また英単語を使うときに間違った綴りを使うのはエラーではないですが、複数人で開発するときに他のメンバー可読性の低下にもつながるので、使い慣れない英単語を使う場合は Google検索で綴りを調べてから使うと良いです。例えば “Actor” を “Acter” と間違って書いてしまうなどです。

さらに「防御」を意味する英単語は “defence” と “defense” の2種類存在します。前者はイギリス英語で後者はアメリカ英語となり意味自体は一緒なのですが、プロジェクトによってどちらを使うかを見極めた上で使用することが大切となります。

適切なコメントを入れる

個人的にはコメントは少ないよりも多いほうが良いと考えています。ただコメントを多くすると保守が大変なので少なくしたほうが良い、という意見もあって正解はありません。

ただどちらであっても言えることとしては、「間違ったコメント」や「わかりにくい文章」のコメントだと読んだ人が理解するまでに時間がかかってしまいます

ここの塩梅は難しいところですが、参考としてタワーディフェンスを作ったときのコードをのせておきます(タワーの配置の処理。このコードだけ Godot Engine の GDScirptです)。

【Godot4.x】タワーディフェンスのサンプルプロジェクト

改めて見るとあまりきれいなコードではないですが、for文やif文など処理の区切りにコメントを入れる、後から処理が変更されるかもしれないところなどに注意してコメントを入れるようにしています。

## 更新 > ビルド(配置).
func _update_build() -> void:
	# カーソルの更新.
	_ui_cursor.position = Map.get_mouse_pos(true)
	_ui_cursor.visible = true
	# 配置できるかどうか.
	var mouse_grid_pos = Map.get_grid_mouse_pos()
	## 地形をチェック.
	var cant_build = Map.cant_build_position(mouse_grid_pos)
	## タワーのチェックも必要.
	for tower in _tower_layer.get_children():
		var grid:Vector2i = Map.world_to_grid(tower.position)
		if grid == mouse_grid_pos:
			# 置けない.
			cant_build = true
			break

  # 配置できないカーソルの状態を更新.
	_ui_cursor_cross.visible = cant_build
	_ui_cursor_tower.visible = (cant_build == false)
	if cant_build == false:
		_ui_cursor_tower.visible = true
	
	# タワーの生存数をカウント.
	var num = 0
	for tower in Common.get_layer("tower").get_children():
		var t:Tower = tower
		if t.get_type() == _buy_type:
			num += 1 # 一致する種別のみ.
	
  # タワーのコストを取得.
	_buy_cost = Game.tower_cost(num, _buy_type)
	if Common.money < _buy_cost or Input.is_action_just_pressed("right-click"):
		# お金足りない or キャンセル.
		_cancel_build()
	elif Input.is_action_just_pressed("click"):
		if cant_build:
			print("ここには建設できない")
		else:
			# ビルド実行.
			_exec_build(_buy_cost)

良いコードを書くために参考となる本

最後に良いコードを書くために参考となる2冊の本を紹介しておきます。

リーダブルコード

多くの人がおすすめしている本なので、あえて紹介する必要はないかもしれませんが、「リーダブルコード」は手元に置いてて損はない、一生使える名著だと思っています。

リーダブルコードは、コードを読みやすくする数多くのテクニックやヒントが詰まっているので、定期的に読み返すと新しい発見があってとても良い本だと思っています。

リファクタリング

こちらも有名どころです。コードの動きを保ったままキレイなコードにするテクニック「リファクタリング」を徹底的に解説した本で、リーダブルコードとはまた違った知見が得られます。

リファクタリングとはそもそも何なのか、リファクタリングを行うべきタイミングである重複したコード」「長すぎる関数」「巨大なクラスについて、逆にリファクタリングをするべきでないタイミング、リファクタリングを行いやすくなる環境作りはどうするべきか、などなど…。

かなり分厚いので読み物というよりも、コードを整理したいときにやりたいことに合わせて辞書的に使える本だと思います。