ゲーム作ってると敵キャラなんか出てきます。これをどうプログラムで動かすか、というのは様々な手法がありますが、今回は完全ではないけれどコストパフォーマンスの高い、入力バッファを使用した手法について解説してみます。
たとえば先日作ったデモはこちらです。
この戦闘機の動きが、どんな実装で作られているか想像つくでしょうか?意外とわからない人は多いんじゃないかな、と思っていますがどうでしょう。DOTSで遊んでいます。たのしいDOTS pic.twitter.com/kSA2zberTi
— ヤスハラユウジ (@dsedb) September 24, 2019
今回はこの実装を題材に、敵キャラ実装を掘り下げてみましょう。ま、敵キャラというかNPCもしくはAIですね。
人間の入力を使う
ゲームアプリケーションはフレーム単位の駆動となるため、時間軸に広がった動きを作るのがけっこう面倒です。そこで時間軸上で現れる「揺らぎ」を人間の入力で表現します。動かしたい敵キャラはそれなりに人間っぽく動いて欲しいので、人間の入力を揺らぎとして使用するのは理にかなっていると言えますね。
基本アイデアはこれがすべて。以降は実装テクニックになっていきます。
ゲームパッド操作を作る
人間の入力というのはすなわち、通常のゲームプレイのことです。つまりこの戦闘機をゲームパッドで操作できるようにするのが、実装の第一歩です。もちろんキーボード操作でも構いませんが、複数のボタンを同時に押しやすいゲームパッドのほうが、質の高い入力データを作れます。
収録データの設計上の注意
ゲームパッドの状態を毎フレーム記録するのですが、デバイス上のシンボルで記録するよりもゲーム上のシンボルで記録したほうが良いです。
つまりフレーム単位の収録データは
// あまり良くない定義:デバイスシンボルで記録
public struct ControllerUnit {
float AxisX;
float AxisY;
bool ButtonA;
bool ButtonB;
bool ButtonX;
bool ButtonY;
}
とデバイスシンボルにするよりも、
// 望ましい定義:ゲームシンボルで記録
public struct ControllerUnit {
float Pitch;
float Yaw;
bool FireBullet;
bool FireMissile;
}
のようにゲームのシンボルにするが望ましい、ということです。こうしたほうが必要なビット数に圧縮するのも用意ですし、収録したデータの汎用性も上がります。
汎用性というのは、例えば今回の戦闘機は「機銃」と「ミサイル」という二つの武器を持っていますが、
機能 | トリガー |
---|---|
機銃 | Aボタン 押しっぱなしで連射 |
ミサイル | Bボタンを 押すたびに一発 |
という仕様にしています。このミサイルのように「押すたびに」というトリガーは、収録時に確定させるのが望ましいです。
こんな感じ。後述するように、収録したデータはけっして単純に収録した通りには再生されません。その応用の余地を残すために、フレーム単位で意味を独立させておく。ゲームのシンボルで収録するのはそのためです。
##操作プログラムを作る
ゲームパッド情報に合わせた戦闘機の行動を作ります。このとき重要なのは、戦闘機の回頭に関してトルクで実装することです。
トルクは加算で合成できるからなのですが、これを守っておくことで応用性に格段の差が現れます。
##収録プログラムを作る
詳細は省略しますが、私は #if RECORDING でC#上のコードの収録/再生を切り替えました。収録モードの場合はゲームパッドで戦闘機を動かし、収録終了の合図で収録した操作バッファをファイルに書き込みます。操作バッファは上記の構造体を配列にしたものです。
NativeList<ControllerUnit> _buffer;
バッファはこんな感じ。NativeListでなくとも普通の配列でもよいでしょう。なお実際には先頭にヘッダとして
・バージョン
・収録フレーム数
などの情報を書き込んでおくと、制作過程で古くなった収録データを無効にするのが容易になります。
生成したファイルは StreamingAssets に保存ます。これで再生モードで読み込むことができます。
ところで、操作情報は基本的にスカスカなので、圧縮したくなるかもしれません。が、フレームをまたがった圧縮はお勧めしません。よっぽど長時間でもない限りファイルサイズは大したことありませんし(今回の例では40KiB弱でした)、圧縮すると汎用性が失われます。堂々と無圧縮で行きましょう。サイズが気になったときは、まずフレーム単位の節約を考えましょう。ほとんどの場合 float は必要ありませんし。
##再生プログラム
再生も難しくありません。ただし、バッファが終端に達したらどうするのか。これは、先頭に戻ることにします。この処理があるので、操作バッファの意味をフレームごとに独立させておく必要がありました。そのおかげで、終端と先頭をつなげても、そこで齟齬が起きないようになっているはずです。
##抽象度を上げる工夫
この話がないと、この作戦はうまく行きません。
操作バッファの尺には限りがあるので、バッファをリピート再生したいのですが、戦闘機の位置は開始時とバッファ終端で当然ながら異なるわけです。
操作バッファで動きを作る際に最も大事なのがここで、「ターゲットの方を向く」という操作を入れるのです。
public struct ControllerUnit {
public float Pitch;
public float Yaw;
public bool FireBullet;
public bool FireMissile;
public bool Toward; // New!!!
}
Toward というボタンが追加されました。これは、
「押している間、ターゲットの方向へ回頭するためのトルクを加える」
という機能になります。いちばん近くのターゲットを捜索し、そちらに向くようなトルクを与えることで、
「場所に関わらず意図通りの動作(ターゲットを向く)をする」
となります。これを適当なボタンにアサインして収録します。私はL1にアサインするのが好きです。
Towardを押すとターゲットへのトルクが働くようになりました。この工夫があることで、操作バッファが格段に汎用的になります。
実際のプログラムでは、これと反対の意味を持つ Away (押している間、ターゲットから背を向けるトルクを発生させる)も導入しています。
##さらに抽象度を上げる工夫
ターゲット方向を向くようになりました。これで「一定期間近づく」行動が可能になった・・・かのように思えますが、まだ問題があります。想定よりもターゲットに近い場所にいた場合、「もう近づいているのに近づこうとする」という、頭の悪そうな行動になってしまいます。
対処のため、操作バッファに「ターゲットとの距離」を格納します。もはや操作情報ではないのですけど。
// 望ましい定義:ゲームシンボルで記録
public struct ControllerUnit {
public float Pitch;
public float Yaw;
public bool FireBullet;
public bool FireMissile;
public bool Toward;
public bool Away;
public float Condition; // New!!!
}
Condition には収録時に、ターゲットとの距離を格納しておきます。
再生時、Towardが有効な期間に限り、再生世界でのターゲットとの距離が収録された距離よりも小さくなったら、Toward有効期間の最終フレームにジャンプする処理を実行します。
これにより「Toward実行期間でも、既にターゲットに近づいていたらすぐ次の行動に移る」ことができるようになりました。
##より知性を持たせる:地形を見る
地面に潜ってはいけないので、地面が近い場合は機種を上げるトルクを発生させます。またターゲットオブジェクトとぶつかりそうになった場合もトルクを発生して避けられるようにします。操作情報による回答動作もトルクで作られていたので、容易に合成できますね。場合によっては合成比率を調整して回避行動を優先するなども効果的です。
##より知性を持たせる:攻撃条件の追加
ターゲットの方向を向いていない場合(内積が規定値よりも小さい場合)は攻撃ボタンが押されていても攻撃しないようにしておきます。あらぬ方向を打ちまくる、のような頭の悪い行動を抑制できます。
##応用:開始位置をずらして複数の戦闘機を発生させる
ここまでの実装で、入力データに従いながらも汎用的な動作をさせることができています。フレーム単位で独立しているので、開始位置はどこでも構いません。よって乱数で複数の戦闘機を発生させ、それぞれの再生開始位置も乱数でずらしておけば、異なった動きをする集団を作ることができます。
##応用2:開始位置を「わずかに」ずらして編隊を作る
3機編隊を作ろうと思ったら、なるべく似た動作をさせる必要があります。初期配置で3機を近くに配置したら、操作バッファの開始位置も近く(30フレーム以内)にするようにしておきます。こうすることで、お互いに追随するような編隊の動きになります。
##より完全を求めて
今回は対応しなかったのですが、収録時と再生時のフレームレートが異なる場合も考慮するのが望ましいです。このとき「押した瞬間のミサイル発射」をどう扱うかが問題になります。再生で想定される最大のデルタTで収録する必要があるでしょう。
#まとめ
人間の入力を使用するのは、人間っぽい動作をさせることにもつながります。また、ノイズ生成ではなく入力を記録したものをソースにすることで、色気のある動きも作れるのではないでしょうか。敵キャラを作る際に覚えておくと便利なテクニックであると同時に、ゲーム以外でも応用が広いように思います。
気付けば今回、タグにUnityつけたけどほとんどUnity関係なかったですね。いちおうコードはC#、バッファの実装例は Unity.Collections です。