先日公開したブラウザゲーム「2D物理エンジンレースカー進化シミュレーター」ですが、
身近な人から「何が面白いのかよくわからない」と言われてしまいました。
せっかく楽しめるゲームなのに、一人で面白さを独占するのはもったいない!そこで、少しでもその魅力を伝えられればと思い解説編の記事を書きました。

遺伝的アルゴリズムの概要

遺伝的アルゴリズムについて詳しく知りたい方は、Wikipediaが参考になります。
遺伝的アルゴリズム – Wikipedia

簡単に説明すると、遺伝的アルゴリズムは「強い個体を選んで次の世代に引き継ぐ」仕組みです。

たくさんの個体(ここではレースカー)を作って競わせ、その中で特に優れたものを選びます。
そして、それらを掛け合わせることで、次の世代はより強くなっていきます。
このプロセスを繰り返すことで、どんどん進化していくのが特徴です。

2D物理エンジンレースカー進化シミュレーターへの遺伝的アルゴリズムの適用

遺伝的アルゴリズムを利用するには、遺伝情報を持つ「遺伝子」が必要であり、それを「掛け合わせる」ことができなければなりません。

しかし、複雑なアルゴリズムそのものを遺伝情報として扱うのは難しそうだったため、まずは簡易的な走行ロボットを作成し、いくつかのパラメータだけで動作するアルゴリズムを組み込みました。

以下が実際の遺伝情報(遺伝子)のコードです。

export interface RobotGene {
    /** 検知する障害物の最小距離 */
    readonly rayCastMinDistance: number;
    /** 検知する障害物の最大距離 */
    readonly rayCastMaxDistance: number;
    /** 検知する障害物の距離に移動速度がどれだけ影響を与えるか */
    readonly rayCastSpeedRatio: number;
    /** 検知した障害物が最大距離に対応するハンドル舵角 */
    readonly steeringMinRatio: number;
    /** 検知した障害物が最小距離に対応するハンドル舵角 */
    readonly steeringMaxRatio: number;
    /** 左右どちらに曲がるかの判定に使うレイキャストの角度オフセット */
    readonly rayCastDirectionOffsetRad: number;
}

※6つの小数の値です。

この走行ロボットは、まず前方の障害物との距離を測定します。

イメージ内赤線のようにまっすぐ伸ばした先に障害物があるかどうかを調べます。

障害物が一定距離以内にある場合、ハンドルを切る動作を行います。
次に、左右どちらに曲がるべきかを判断するため、追加の距離測定を行います。

イメージ内の青線のように、少し角度をつけて左右の障害物との距離を測定し、障害物との距離が大きい方へハンドルを切る仕組みです。
図で言えば左のほうが青線が長いのでそちらへ曲がります。

非常にシンプルな動作ですね。

しかし、シンプルすぎるため、もう少し柔軟な動作ができるように、追加のパラメータを持たせています。
具体的には、以下のような調整が可能になっています。

  • 障害物が近いときは大きくハンドルを切り、遠いときは小さくハンドルを切れるようにしました。
  • 速度が速いときは遠くまで障害物を検知できるようにしました(rayCastSpeedRatio)。
  • 左右判定用の当たり判定の角度をパラメータにしました(rayCastDirectionOffsetRad)。

なお、今回のシミュレーターでは、あくまで「人間が操作するときと同じ条件」でパラメータを調整しており、車体の性能自体には一切手を加えていません。
(車の性能自体を進化させるのも面白そうですが、今回は見送っています。)

第1世代のレース開催

各パラメータをランダムな値(※範囲指定有り)で初期化し、20台のロボットを作成します。
これを「第1世代」としてレースを開催します。

案外これでもレースっぽく見えます!

ランダムなパラメータですからレース展開も個体差があり、見ていて意外と面白いです。

レースゲームらしく、各ロボットのラップタイムを計測し、左下のランキングボードにベストラップを表示しています。

(※名前はランダムに生成されているだけなので意味はありません!「藤原」姓だからといって速くなるわけではないです。)

そして、ランキングボードの順位がしばらく変動しなくなったら、レースを自動で終了し、次の世代を生成します。

第2世代のレース開催

次世代の作成方法にはさまざまなアルゴリズムがありますが、今回は簡単な方法を採用しました。
具体的には、ランキングボードの1位の個体と、2~4位の中からランダムに選んだ1体のパラメータを掛け合わせて(※交叉させて)次の世代を作成します。

        for (let i = 0; i < maxRobot; i++) {
            const robotA = sorted[0]; // 一番早いロボットは必ず選択する
            const robotB = sorted[1 + Math.floor(Math.random() * 3)]; // もう片方は少しランダムにする
            genes.push(crossover(robotA.robotDriver.gene, robotB.robotDriver.gene));
        }

交叉の仕組みも単純で、各パラメータごとに「個体Aの値と個体Bの値の範囲内のどこかをランダムに採用する」 という方法を採用しました。
(個体Aまたは個体Bの値をそのまま継承する方法もありますが、次世代のバリエーションが少なくなってしまうため、今回の方法にしました。)

今思えば、範囲外の値も一定の確率で取り入れる ことで、より多様性が生まれて進化の幅が広がったかもしれませんね。

こうして、「2体の個体の選定→交叉」を繰り返すことで次世代の20台を作成し、第2世代のレースを開催します。

各世代を追うことで最適化されるパラメーター

世代が進むにつれてパラメータが最適化され、より速いベストラップタイムを出せる個体が生き残っていきます。
単純なアルゴリズムで動作しているのに、ロボットがコーナーのインを攻めるような動きをするのは見ていて驚きますしここが面白く感じるポイントでもあります。

今回採用した遺伝的アルゴリズムでは、第1世代で速かった個体の特徴が次世代に強く引き継がれるという性質があります。

例えば、第1世代で「壁にぶつかって跳ね返ることで速度を維持する」個体が1位を取ったとします。
すると、第2世代以降もこの特性をさらに強化する方向で進化していくため、別の戦略や走り方を持つ個体が生まれにくくなるという問題が発生します。
(※一応「突然変異」の要素も取り入れていますが、あまり効果的には働いていないようです。)

この結果、ある程度世代が進むと、レース展開が単調になってしまうという課題が出てきました。
ベストラップタイム自体はどんどん速くなっているものの、レースとしての見応えは少し薄れてしまったように思います。
この点は、ゲームとしての反省点の一つです。
しかし、逆に言えば「第1レースからやり直せば、また違った進化のパターンが生まれる可能性が高い」とも言えます。

開発を終えて

今回の目標は、「進化するゲームを作りたい(3連休の間に)」 という単純なものでした。
しかし、実際に開発を始めたときは、最終的にどんな結果になるのかまったく分からない状態でした。

特に、遺伝的アルゴリズムを導入する前の段階で、
「そもそも車をトップダウンビューでどうやって走らせるのか?」 という問題に直面し、
物理エンジンの扱いも手探りで進めていくしかありませんでした。

正直なところ、「もしかしたら何も進化せずに終わるかもしれない」という不安もありました。
しかし、最終的にはしっかりと進化の過程が見える形になり、良い結果が得られたと思います。

今回の開発を通して、進化させるゲームを作るうえで重要な要素が見えてきました。

  • パラメーターによって動作が変化する対象(ロボット、生物など)
  • 進化の場となるフィールド(レース場、狩り場 など)
  • 明確な勝敗ルール(速さ・生存率など)
  • ある程度「ポンコツ」でも成功できる設計

レースゲームの場合、とりあえず1周できるポンコツロボットを作ることは容易にできましたが、
他のジャンルで同じように条件を整えるのは、なかなか難しそうだとも感じました。
(パラメーターによって摩訶不思議な生物を生み出す、とかは難しそうですよね)

とはいえ、こういった仕組み次第で、まだまだ面白いゲームが作れる可能性がある分野だとも思います。

この記事を読んだ方が、次世代の遺伝的アルゴリズムを活用したゲームを作ってくれることを願って、私の「世代」はここで終わりにしたいと思います。(上手な着地!)

記事検索

アーカイブ