この記事では筆者が過去に開発し、Steam でリリースした「盗賊少女」と、このゲームで用いた被発見アルゴリズムについて紹介していきたいと思います。
作り始めたきっかけ
筆者の好きなゲームの一つはメタルギアソリッド(MGS)シリーズです。 一番最初に遊んだのがMGS4で、遊び始めた時はアクションゲームだと勘違いしておりました…。 しばらく遊んで称号を集めているうちに、敵を倒すのではなく敵に見つからないように、そして倒さないようにゴールまでたどり着くのがゲームのコンセプトだということに気づき、過去作品まで含めて遊ぶようになりました。 印象的だったのが敵に見つかる時の発見判定で、視覚・聴覚・触覚それぞれを考慮したアルゴリズムになっていそうだと感じ、 同じようなゲームを自分でも作ってみようと思ったのが開発のきっかけです。
発見判定のアルゴリズム
敵に見つかったかどうかを判定するため、「盗賊少女」では視覚・聴覚・触覚の3つの観点で判定を行っています。 ここでは簡単な判定手段から順に紹介していきます。 「盗賊少女」はUnity を用いて開発しているため、要所要所でUnity のソースコードも交えて説明していきます。
触覚判定
これが一番単純な判定処理で、主人公と敵との距離が閾値以下だった場合に接触したと判定します。 判定処理は下記のようなコードになります。
/// <summary> /// 敵に接触したかどうかを判定する /// </summary> /// <param name="player">主人公</param> /// <param name="enemy">敵</param> /// <param name="threshold">この距離よりも主人公と敵が互いに近づいたら接触したと判定する</param> /// <returns>敵に接触していたらtrue</returns> public static bool IsTouched(Transform player, Transform enemy, float threshold) { return Vector3.Distance(player.position, enemy.position) < threshold; }
聴覚判定
これも触覚の判定と同様、主人公と敵との距離が閾値以下だった場合に音を聞きかれたと判定します。 但し、触覚の判定と異なる点は「音が鳴った場合に限り」という条件が加わります。
動きのバリエーション
ここは工夫のしどころがあるポイントです。
標準的な判定手段は「主人公が動いていれば音が鳴った」と解釈することです。これに加えて、主人公の姿勢を考慮すると遊び方の幅が広がります。走っている時と歩いている時とでは、走っている時の音の方がよく聞こえ、その分敵に見つかりやすくなります。同様に、しゃがんでいたり、ゆっくり歩いていたり、匍匐前進していたりと、動き方によって見つかりやすさが変わってきます。
移動の速さと見つかりやすさはトレードオフの関係にあります。 これらの動き方を使い分けることで、最終的に敵にみつからないようなバランスを考える工夫が生まれ、ゲームクリアの達成感へとつなげることができます。
囮の導入
音源は主人公の足音だけではありません。瓶や樽などの障害物を動かしたときにも音が鳴ります。
障害物の種類により、例えば樽や椅子などの木材であれば鈍い音がなる、つまり少し聞こえづらい音として聴覚判定します。一方、瓶のようなかたい材質であれば鋭い音が鳴る、つまり聞こえやすい音として聴覚判定します。
障害物の場合、主人公から離れて下に転げ落ちていくこともあります。これを利用し、主人公から遠くで音が鳴る状態を作ることで、障害物を囮として活用し、敵を遠ざけることができるようになります。
音の大きさとアルゴリズム
音が鳴った時、聞こえやすい音ほど遠くの敵まで聞こえます。このことを考慮して、聞こえやすい音がなった時はより大きな閾値を用いて主人公と敵との距離が閾値以下かどうかを判定するようにします。
視覚判定
最も複雑なアルゴリズムが敵の視界に入ったかどうかを判定する処理です。 下記の3ステップで判定します
- 主人公と敵の距離は一定距離以内か?
- 主人公は敵の視野の範囲内か(障害物は無視する)
- 主人公と敵との間に障害物はないか?
主人公と敵の距離は一定距離以内か?
これは触覚や聴覚の判定と同様、主人公と敵との距離が閾値以下だった場合に「敵に発見されているかもしれない」と解釈し、次の判定処理を実施します。言い換えると、この距離が閾値を上回っている場合は「敵に発見されていない」ことが確定します。
触覚・聴覚の判定処理で敵と主人公との距離は既に求めているため、距離の判定処理は最も軽い処理です。したがって、視覚判定の3種類の判定処理のうち、距離の判定処理は一番最初に実施し、敵に発見されていないことが確定したら残りの2種類の判定処理を実施しないようにして処理の軽量化を図ります。
主人公は敵の視野の範囲内か(障害物は無視する)
この判定処理では障害物の存在を一旦無視します。 「敵の正面方向」と「敵の視野の端への方向」とがなす角をaとします。 「敵の正面方向」と「敵から主人公への方向」とがなす角をbとします。 この時、b<a であれば主人公が敵の視野の範囲内に存在することがわかります。
さて、a は閾値ですので予め何らかの値を設定しておけばよいとして、b はどうやって求めるのでしょうか?これは高校の授業で習った内積を用いることで解決できます。
- c=敵の正面方向への正規化ベクトル(長さ1)
- e=敵から主人公への方向への正規化ベクトル(長さ1)
とすると、内積の公式よりa, b, c, e は以下のような関係式となります。
cos(b) = 内積(*c*, *e*) / (|*c*||*e*|)
ここでcとe は正規化ベクトルで長さが1ですので、上記の式は下記のようになります。
cos(b) = 内積(*c*, *e*)
となります。
では、cos(b)の逆関数を使ってb を求めるかというとその必要はありません。
主人公が敵の視野内にいるとき|a| > |b| となりますが、この時cos(a) < cos(b) となります。 したがって、予めcos(a)を求めておけば、以下の条件が成り立つときに主人公が敵の視野内にいると判定できます。
内積(*c*, *e*) > cos(a)
主人公と敵との間に障害物はないか?
主人公が敵の視野内にいる場合、主人公と敵の間に障害物がないかどうかを判定します。 障害物があれば、主人公は敵から見えないことになります。
障害物の有無を判定するにはUnity のRay クラスとPhysicsクラスのRaycast メソッドを使用します。
Ray ray = new Ray(enemy.position, dir); RaycastHit hit; // 敵から主人公にレイを飛ばすと、間にある障害物にあたるかもしれないので確認する。 if (Physics.Raycast(ray, out hit, distance)) // チェックポイント3:その障害物が敵と主人公の間にある { // 主人公は壁に隠れて見えない return false; } // 主人公は壁に隠れておらず、敵の視野内にいるので見つかっている return true;
この当たり判定判定処理は3種類の判定処理の中で最も負荷のかかる処理です。 そのため、まず負荷の軽い2種類の判定処理を行い、「主人公が敵の視野内にいるかもしれない」ということがわかった時点でこの負荷のかかる当たり判定処理を行うことで、全体的な処理負荷を下げるようにしましょう。
ソースコード全体
以上3つの判定処理を行って視界に入っているかどうかを判定するためのプログラムを下記に示します。
/// <summary> /// 主人公が敵の視界に入っているかどうかを判定する /// </summary> /// <param name="player">主人公</param> /// <param name="enemy">敵</param> /// <param name="range">この距離よりも離れていたら敵から主人公は見えない</param> /// <param name="viewAngle">この角度より外側にいたら敵から主人公は見えない</param> /// <returns>主人公が敵の視界に入っていればtrue</returns> public static bool IsFound(Vector3 player, Transform enemy, float range, float viewAngle) { Vector3 dir = (player - enemy.position); // 敵から主人公への法線ベクトル float distance = (player - enemy.position).magnitude; // 敵と主人公の距離 if (distance <= range) // チェックポイント1:主人公と敵の距離は一定距離以内か? { Vector3 ndir = (player - enemy.position).normalized; // 敵から主人公への正規化済みベクトル Vector3 nforward = enemy.forward; // 敵の正面方向への正規化ベクトル float dot0 = Vector3.Dot(nforward, ndir); // 内積 float limit0 = Mathf.Cos(viewAngle * 0.5f * Mathf.PI / 180.0f); if (dot0 >= limit0) // チェックポイント2:主人公は敵の視野の範囲内か(障害物は無視する) { Ray ray = new Ray(enemy.position, dir); RaycastHit hit; // 敵から主人公にレイを飛ばすと、間にある障害物にあたるかもしれないので確認する。 if (Physics.Raycast(ray, out hit, distance)) // チェックポイント3:その障害物が敵と主人公の間にある { // 主人公は壁に隠れて見えない return false; } // 主人公は壁に隠れておらず、敵の視野内にいるので見つかっている return true; } } return false; }
おわりに
この記事ではステルスアクションゲームで重要な発見判定アルゴリズムについて紹介しました。 聴覚の判定処理の時に様々なバリエーションが存在したように、視覚の判定処理にも色々なバリエーションがあります。 例えば下記のような工夫が考えられます。
- 主人公の服装に応じて見つかりにくくする
- 見つかった時の距離に応じて怪しませたり攻撃を仕掛けてきたりと、敵のふるまいを変える
自分でステルスアクションゲームを開発する時には、このような工夫を色々凝らして是非個性的なゲームを作っていきましょう。
ところで、今回の説明ではベクトルや線形代数に関する記述があり、数学が苦手だと難しい内容だったかもしれません。 数学が苦手な原因の一つは「これ、何の役に立つんだろう?」と必要性がわからず、とっつきにくいことではないかと思います。 その一方で、Unity やUnreal Engine などを用いた3DCGのゲームでは色々なところで数学の知識が必要になります。 今回の判定処理であれば、三角関数やベクトルの長さの比較や内積の考え方についての理解が必要です。
そこで、面白いゲームを作りながら数学が好きになるように、
- 本記事のようなブログを読んで、数学のどのような知識が必要になるかを知る(何の役に立つのかがわかる!)
- とりあえずその分野だけでよいので勉強してみる(文章問題と計算の過程は大事!)
- 他の人が作ったゲームと同じようなゲームを自分で作ってみる(勉強した数学を応用する!)
といった流れで数学の勉強とゲーム開発とを試行錯誤しながら進めていくことをお勧めします。 (筆者はこのノリで大学入試を乗り切りました(笑))
記:azarashin