見出し画像

clusterでそこそこいい感じに飛ばせて戦える感じのNPC戦闘機(力技)を作ったメモ

はじめまして。バーチャルでもリアルでもそこら辺の草葉の陰にいる一般魔女のミソです。
この記事は、
cluster民とクラフトする非公式 Advent Calendar 2023
25日目の記事です。
いやあ、最終日ですね。トリですよ。こういう記事を書くのは初めてですのでどうかご容赦ください。
お茶でもコーヒーや紅茶でもetcでも飲みながら見て頂ければと思います。
はい、というわけでトリですのでトリを作っていきましょう。
鋼鉄の鳥を。👍( ゚Д゚)👍作っていくよ!

clusterScriptを使ったclusterで飛ばせるNPC無人戦闘機を作っていきましょう。

・初めに注意事項です。筆者(ミソ)は普通の一般ユーザーです。決してUnityやプログラミング、数学、デザインの専門家ではありません。またこの記事で作るNPC戦闘機はたくさんの先人様たちの参考文献をあさり、参考にして筆者が作った素人クオリティのものです。(専門家になりたい…)

ただただcluster上で筆者がドックファイトしたいだけの欲望で作ったものです。こんな感じに作ったんだなあって感じで見てください。

初めに環境を準備します。
clusterのワールドで飛ばすにはUnityとCCKを使います。
なお環境構築やツールの導入については、clusterさんの素晴らしい公式記事、クリエイターズガイド(https://creator.cluster.mu/)にとてもわかりやすく書かれておりますので、そちらをご覧ください。
また筆者は
・Cluster World Tools(cluster様  https://creator.cluster.mu/2023/06/19/clusterworldtools/)
・CSEmulator V2 (かおもラボ様  https://booth.pm/ja/items/5111235)
の2つのツールを入れております。
この2つのツールを使うことで、特にスクリプト開発が凄く凄く加速して捗りますので本当におすすめです。

オブジェクトの構造

スクリプトで処理するためにオブジェクトの構造を作っていきます。
以下のような感じで筆者は作りました。


またTarget_Pos_0~Target_Pos_3にはTarget_0~Target_3の座標
を取得するためにそれぞれ、Parent Constraitコンポーネントを付けてください。

スクリプト

次にスクリプトを書いていきます。
その前にNPC機の本体とする「NPC_AUFXX_01」オブジェクトに「Scriptable Item」と「Movable Item」コンポーネントを付けてあげましょう。
またついでにアニメーターコンポーネントとアニメーターコントローラーを
付けてください。
アニメーターコントローラーの名前はなんでもいいですが、
筆者は「NPC_Anim」と名付けました。
またルートモーションを適用にチェックを入れておいてください。

これでスクリプトで移動するアイテムを作る準備ができたと思います。

さてスクリプトを書いていきます。
スクリプトを書くツールはなんでもよいのですが、ここでは筆者もよく使っている。Visual Studio Code(VSCode)で話を進めていきます。
とりあえず以下のような感じで書きます。

// cluster上で動作するNPC戦闘機のスクリプト
$.onUpdate(deltatime => {
    if(!$.state.initialized) {
        $.state.initialized = true;
    }

    let Cur_Pos = $.getPosition(); // 現在の位置を取得する
    $.setStateCompat("this", "Current_Position_______ : ", Cur_Pos);
    
    // 移動する
    $.setPosition(Cur_Pos.clone().add(new Vector3(0, 0, 1).multiplyScalar(1 * deltatime)));
})

これでUnityの再生ボタンを押すと、前方に前進すると思います。
また、この時に、ClusterWorldToolsのトリガーモニターを起動し、
スクリプトが付いた「NPC_AUF_XX_01」オブジェクトをアタッチすると
オブジェクトの現在位置を知ることができます。
(これが地味に便利で、筆者は感動してました。とは言えもう誰かが知っているでしょうし、なんなら常識で今更かもしれない。。。まあいいや。)

こんな感じ
便利!

さて、機体を前進させることは出来ました。
しかし戦闘機は相手(敵機)を追いかけるものです。
このままではずっと前進し続けるだけです。
ですので、追いかける機能を付与する必要があります。
戦闘機は3次元(X, Y, Z)移動するものですので、3次元追跡を実装する必要があります。
筆者はこれが地味に難しく、よくわからず苦労しました。
cluster公式サンプルには2次元追跡のコードはあっても、3次元追跡のコードはなかったので、途方にくれました。
しかし、vins様がなんと3次元追跡のサンプルを作ってくださっていました。(https://zenn.dev/vins/articles/649a70dfe2ab17)まさに救いでした。本当にありがとうございます!
さて、このサンプルコードを使わせて頂き、ターゲットを追跡する
コードを書き上げていきます。
簡略化のため、ここでは機体から最短距離の標的を追いかける
実装とします。

// cluster上で動作するNPC戦闘機のスクリプト
// Parent Constraitで標的にくっつけた子オブジェクトを定義。
const Target_Objects = [
    $.subNode("Target_Pos_1"),
    $.subNode("Target_Pos_2"),
    $.subNode("Target_Pos_3"),
    $.subNode("Target_Pos_4")
];

const rad2deg = (rad) => (rad * 180) / Math.PI;

$.onUpdate(deltatime => {
    if(!$.state.initialized) {
        $.state.initialized = true;
    }

    let Cur_Pos = $.getPosition(); // 現在の位置を取得する
    $.setStateCompat("this", "Current_Position_______ : ", Cur_Pos);

    let Current_Target_Dis = null; // 現在一番近くのターゲットとの距離
    let Current_Target_Pos = null; // 現在一番近くのターゲットの座標(XYZ)
    // lengthはjavascriptの配列のプロパティ
    // ほびわんさんのClusterScript作例ノートより
    // https://scrapbox.io/hobione-note/ClusterScript%E4%BD%9C%E4%BE%8B
    for(let i = 0; i < Target_Objects.length; i++)
    {
        let Cur_Pos = Target_Objects[i].getGlobalPosition().clone(); // ベータ機能。グローバル座標を取得する
        let Cur_Dis = Cur_Pos.clone().sub(Cur_Pos).lengthSq(); // length()と違い、2乗の計算を使わないため、早いらしい?
        // 現在一番近くのターゲットがまだ定まっていない
        if(Current_Target_Dis == null) {
            Current_Target_Dis = Cur_Dis;
            Current_Target_Pos = Cur_Pos;
        }
        // 前に決めた最短距離のターゲットよりも現在のターゲットの方が距離が短い
        // (今のターゲットの方が近い)
        if(Cur_Dis < Current_Target_Dis) {
            Current_Target_Dis = Cur_Dis;
            Current_Target_Pos = Cur_Pos;
        }
    }

    // 最短距離のターゲットがいる方向を計算してY軸回転する
    let t_direction = Current_Target_Pos.clone().sub(Cur_Pos); // これでターゲットへの方向が
    let t_angle = rad2deg(Math.atan2(t_direction.x, t_direction.z));
    let t_rotation = new Quaternion().setFromEulerAngles(new Vector3(0, t_angle, 0));
    
    $.setRotation(t_rotation); //これターゲットの方向に向く
    // 移動する
    $.setPosition(Cur_Pos.clone().add(new Vector3(0, 0, 1).multiplyScalar(1 * deltatime)));
})

さて、これでとりあえず機体から見て、一番近くのターゲットを向くことは出来ました。
しかし、まだこれでは機体は向いてもオブジェクトにかかる前進する力?(こう言っていいのかわからん…)は相変わらずZ方向にかかったままです。
そこで、回転して敵機に向いた力をこんどは機体を前進する力に適用させてみないといけません。
当時(2023年の12月初旬ごろ?だったけ?)、何かいい方法がないか探していたら「ねおりん」様の記事「cluster に飛行機を実装する【2022年最新版】」(https://zenn.dev/noir_neo/articles/09087a159d8479
の中の、機体の姿勢に関する記述ありました。
どうやら、機体には進行方向を向こうとする力が働くので、それを再現するために、LookRotation()というのが必要みたいです。
そこで、このLookRotation()を使えば機体の向きをターゲット方向に維持させたまま、機体の前進する力をターゲットの方向に適用させることができるかもしれません。(書いてて難しくなってきた…)
このLookRotation()をありがたく使わせて頂き、実装したのが以下のような感じです。

// cluster上で動作するNPC戦闘機のスクリプト
// Parent Constraitで標的にくっつけた子オブジェクトを定義。
const Target_Objects = [
    $.subNode("Target_Pos_1"),
    $.subNode("Target_Pos_2"),
    $.subNode("Target_Pos_3"),
    $.subNode("Target_Pos_4")
];

const rad2deg = (rad) => (rad * 180) / Math.PI;
// https://answers.unity.com/questions/467614/what-is-the-source-code-of-quaternionlookrotation.html
// ねおりん様の記事より引用 URL https://zenn.dev/noir_neo/articles/09087a159d8479#%E6%A9%9F%E4%BD%93%E3%81%AE%E5%A7%BF%E5%8B%A2
const lookRotation = (forward, up) => {
    forward = forward.clone().normalize();
    const right = up.clone().cross(forward).normalize();
    up = forward.clone().cross(right);
  
    const m00 = right.x;
    const m01 = right.y;
    const m02 = right.z;
    const m10 = up.x;
    const m11 = up.y;
    const m12 = up.z;
    const m20 = forward.x;
    const m21 = forward.y;
    const m22 = forward.z;
  
    const num8 = (m00 + m11) + m22;
    if (num8 > 0)
    {
      var num = Math.sqrt(num8 + 1);
      const w = num * 0.5;
      num = 0.5 / num;
      const x = (m12 - m21) * num;
      const y = (m20 - m02) * num;
      const z = (m01 - m10) * num;
      return new Quaternion(x, y, z, w);
    }
    if ((m00 >= m11) && (m00 >= m22))
    {
      var num7 = Math.sqrt(((1 + m00) - m11) - m22);
      var num4 = 0.5 / num7;
      const x = 0.5 * num7;
      const y = (m01 + m10) * num4;
      const z = (m02 + m20) * num4;
      const w = (m12 - m21) * num4;
      return new Quaternion(x, y, z, w);
    }
    if (m11 > m22)
    {
      var num6 = Math.sqrt(((1 + m11) - m00) - m22);
      var num3 = 0.5 / num6;
      const x = (m10 + m01) * num3;
      const y = 0.5 * num6;
      const z = (m21 + m12) * num3;
      const w = (m20 - m02) * num3;
      return new Quaternion(x, y, z, w);
    }
    var num5 = Math.sqrt(((1 + m22) - m00) - m11);
    var num2 = 0.5 / num5;
    const x = (m20 + m02) * num2;
    const y = (m21 + m12) * num2;
    const z = 0.5 * num5;
    const w = (m01 - m10) * num2;
    return new Quaternion(x, y, z, w);
  };

$.onUpdate(deltatime => {
    if(!$.state.initialized) {
        $.state.initialized = true;
    }
    let Cur_Pos = $.getPosition(); // 現在の位置を取得する
    $.setStateCompat("this", "Current_Position_______ : ", Cur_Pos);

    let Current_Target_Dis = null; // 現在一番近くのターゲットとの距離
    let Current_Target_Pos = null; // 現在一番近くのターゲットの座標(XYZ)
    let C_Index = null;
    // lengthはjavascriptの配列のプロパティ
    // ほびわんさんのClusterScript作例ノートより
    // https://scrapbox.io/hobione-note/ClusterScript%E4%BD%9C%E4%BE%8B
    for(let i = 0; i < Target_Objects.length; i++)
    {
        let T_Cur_Pos = Target_Objects[i].getGlobalPosition().clone(); // ベータ機能。グローバル座標を取得する
        let T_Cur_Dis = T_Cur_Pos.clone().sub(Cur_Pos).lengthSq(); // length()と違い、2乗の計算を使わないため、早いらしい?
        // 現在一番近くのターゲットがまだ定まっていない
        if(Current_Target_Dis == null) {
            Current_Target_Dis = T_Cur_Dis;
            Current_Target_Pos = T_Cur_Pos;
        }
        // 前に決めた最短距離のターゲットよりも現在のターゲットの方が距離が短い
        // (今のターゲットの方が近い)
        if(T_Cur_Dis < Current_Target_Dis)
        {
            Current_Target_Dis = T_Cur_Dis;
            Current_Target_Pos = T_Cur_Pos;
        }
    }

    // 最短距離のターゲットがいる方向を計算してY軸回転する
    // vins様の記事を引用 引用元 https://zenn.dev/vins/articles/649a70dfe2ab17
    let t_direction = Current_Target_Pos.clone().sub(Cur_Pos); // これでターゲットへの方向が
    let t_angle = rad2deg(Math.atan2(t_direction.x, t_direction.z));
    let t_look_Rot = lookRotation(t_direction, new Vector3(0, t_angle, 0)); // 引数forwardにはターゲットへの方向、 upにはターゲットへのY軸方向ベクトル
    
    $.setRotation($.getRotation().clone().slerp(t_look_Rot, 0.1)); //これターゲットの方向に向く
    // 移動する normalizeは正規化。これで方向を保ったまま単位ベクトルにできる。はず。。 
    t_direction.normalize().multiplyScalar(1 * deltatime);
    $.setPosition(Cur_Pos.clone().add(t_direction));
})

これで、一番近くのターゲットに向かって移動してくれるようになりました。
さて、ここまでで自機から見て最短距離のターゲットに向けて3次元追尾してくれる戦闘機っぽい感じなものが出来ました。
しかし、このままではターゲットに延々と追跡し続けていってしまいます。
それでもいいですが、もう少しいい感じに作ってみたいですね。

よりいい感じに

さて、よりいい感じに動いてくれるにはどうしたらいいのか?
色々な考えがありますが、ここでは筆者は次のように考えました。

  • すごい接近したら、一旦誘導を切ってもらう。(こうすることでずっと追跡して衝突してガクガクにならない)

  • ある程度の行動範囲を決めて、その範囲から出たら例えターゲットを追跡中でも追跡を切って範囲内に戻る。

とりあえずこの2つでしょうか?
あまりいっきに増やすと混乱するので、この2つを実装していきます。

追跡中から自由行動へ、そして再度追跡。

さて、現在の実装は機体から見て最短距離のターゲットに向けて
追跡する実装ですが、これを追跡中→追跡なし→追跡中と
状態変えていく実装に変更します。
追跡中はオブジェクトの方向と回転を最も近くターゲットに向けて
行っていますが、追跡なしの時はどうしましょう?
とりあえずここでは先ほどのねおりん様の記事をなぞって、
機体の進行方向に向かせてあげましょう。
そうすることで自然な機動を描いてくれるような気がします。
機体の進行方向に回転と方向を向かせるには、lookRotation()の引数に
機体の進行方向とY軸だけに1を入れた3次元ベクトルを作って渡して上げればいけるはずです。(これもねおりん様の記事を参考にしました。感謝です…!)
またターゲットととの距離も図り距離3m(2乗で計算してるのでここでは9)以内に入ったら誘導を切るとします。
以下のような感じになりました。

// cluster上で動作するNPC戦闘機のスクリプト
// Parent Constraitで標的にくっつけた子オブジェクトを定義。
$.onUpdate(deltatime => {
    if(!$.state.initialized) {
        $.state.initialized = true;
    }

    // 機体の進行方向にかかる力を取得する
    // 引用元 かせー様の記事 URL https://note.com/kasei_s/n/nf6a30f35a2b5
    const directionZ = new Vector3(0, 0, 1).applyQuaternion($.getRotation());
    let Cur_Pos = $.getPosition(); // 現在の位置を取得する
    $.setStateCompat("this", "Current_Position_______ : ", Cur_Pos);

    let Current_Target_Dis = null; // 現在一番近くのターゲットとの距離
    let Current_Target_Pos = null; // 現在一番近くのターゲットの座標(XYZ)
    let C_Index = null;
    // lengthはjavascriptの配列のプロパティ
    // ほびわんさんのClusterScript作例ノートより
    // https://scrapbox.io/hobione-note/ClusterScript%E4%BD%9C%E4%BE%8B
    for(let i = 0; i < Target_Objects.length; i++)
    {
        let T_Cur_Pos = Target_Objects[i].getGlobalPosition().clone(); // ベータ機能。グローバル座標を取得する
        let T_Cur_Dis = T_Cur_Pos.clone().sub(Cur_Pos).lengthSq(); // length()と違い、2乗の計算を使わないため、早いらしい?
        // 現在一番近くのターゲットがまだ定まっていない
        if(Current_Target_Dis == null) {
            Current_Target_Dis = T_Cur_Dis;
            Current_Target_Pos = T_Cur_Pos;
        }
        // 前に決めた最短距離のターゲットよりも現在のターゲットの方が距離が短い
        // (今のターゲットの方が近い)
        if(T_Cur_Dis < Current_Target_Dis)
        {
            Current_Target_Dis = T_Cur_Dis;
            Current_Target_Pos = T_Cur_Pos;
        }
    }

    // 最短距離のターゲットがいる方向を計算してY軸回転する
    // vins様の記事を引用 引用元 https://zenn.dev/vins/articles/649a70dfe2ab17
    let t_direction = Current_Target_Pos.clone().sub(Cur_Pos); // これでターゲットへの方向が
    let t_angle = rad2deg(Math.atan2(t_direction.x, t_direction.z));
    let t_look_Rot = lookRotation(t_direction, new Vector3(0, t_angle, 0)); // 引数forwardにはターゲットへの方向、 upにはターゲットへのY軸方向ベクトル
    
    // 距離3いないなら通常の移動へ 2乗なので9
    if(Current_Target_Dis > 9)
    {
        $.setRotation($.getRotation().clone().slerp(t_look_Rot, 0.009)); //これターゲットの方向に向く
        // 移動するnormalizeは正規化。これで方向を保ったまま単位ベクトルにできる。はず。。
        t_direction.normalize().multiplyScalar(5 * deltatime);
    }
    $.setPosition(Cur_Pos.clone().add(new Vector3(0, 0, 5).multiplyScalar(1 * deltatime).applyQuaternion(lookRotation(directionZ, Vector3Up))));
});

これで、ターゲットととの距離が3m以内に誘導を切ってずっと衝突し続けることはなくなりました。
slerpの第二引数は0.09とかなり小さくしました。こうすると誘導のかかりが緩やかになります。(調べるとターゲットの距離を球状補間するとからしい…よくわからん…。)
$.setPositionを最後に必ず実行させるように条件文を書きます。
こうすることで、$.setRotationで回転を設定しつつ、lookRotationにより機体の進行方向への力を保ったまま誘導を行えるようにします。
(正直あってるのかわからん…。でもこれでいい感じに動いてくれるからこれでいいのです。(;´・ω・)ユルシテ)

エリア外に出た場合

エリア外に出た場合に、エリア内に戻る行動を作ります。
まず、行動範囲を決めます。
ここでは行動範囲をX:-20~20 Y:5~20 Z: -20~20とし、それを出た場合、
強制的にワールドの中央に戻すようにします。
ワールド中央は原点(0,0,0)でいいですが、ある程度の高度は欲しいので
Y:10くらいにします。
Unityヒエラルキーから、空のゲームオブジェクトを作って名前を
「World_Origin_Point」として、X:0, Y:10, Z:0のところに設置します。
そして最初の方に作った「NPC_AUFXX_01」の子オブジェクト
「World_Origin」オブジェクトにParent Constraitを付けて、
コンストレイト対象に「World_Origin_Point」を設定してあげてください。

こんな感じ

次はスクリプトですが、追跡中と自由行動(?)に条件文を付け加えてもいいですが、ここはreturn文を使って戻ってる最中では他の処理はスキップさせます。
return文で切ってるので、最後の$setPosition()の代わりに原点に戻る用の
$.setPosition()を追加します。
大体以下のような感じ。

$.onUpdate(deltatime => {
    if(!$.state.initialized) {
        $.state.initialized = true;
    }

    // 機体の進行方向にかかる力を取得する
    // 引用元 かせー様の記事 URL https://note.com/kasei_s/n/nf6a30f35a2b5
    const directionZ = new Vector3(0, 0, 1).applyQuaternion($.getRotation());
    const WO_Pos = WOP.getGlobalPosition();
    let Cur_Pos = $.getPosition(); // 現在の位置を取得する
    $.setStateCompat("this", "Current_Position_______ : ", Cur_Pos);
    
    // 以下の範囲を出た場合に、強制的に中央に戻す。
    if(Cur_Pos.x > 20 || Cur_Pos.x < -20 || Cur_Pos.y > 20 || Cur_Pos.y < 5 || Cur_Pos.z < -20 || Cur_Pos.z > 20)
    {
        let w_origin_direction = WO_Pos.clone().sub(Cur_Pos);
        let w_lk_rot = lookRotation(w_origin_direction, Vector3Up);
        $.setRotation($.getRotation().clone().slerp(w_lk_rot, 0.5));
        w_origin_direction.normalize().multiplyScalar(20 * deltatime);
        $.setPosition(Cur_Pos.add(w_origin_direction));
        return;
    }

    let Current_Target_Dis = null; // 現在一番近くのターゲットとの距離
    let Current_Target_Pos = null; // 現在一番近くのターゲットの座標(XYZ)
    let C_Index = null;
    // lengthはjavascriptの配列のプロパティ
    // ほびわんさんのClusterScript作例ノートより
    // https://scrapbox.io/hobione-note/ClusterScript%E4%BD%9C%E4%BE%8B
    for(let i = 0; i < Target_Objects.length; i++)
    {
        let T_Cur_Pos = Target_Objects[i].getGlobalPosition().clone(); // ベータ機能。グローバル座標を取得する
        let T_Cur_Dis = T_Cur_Pos.clone().sub(Cur_Pos).lengthSq(); // length()と違い、2乗の計算を使わないため、早いらしい?
        // 現在一番近くのターゲットがまだ定まっていない
        if(Current_Target_Dis == null) {
            Current_Target_Dis = T_Cur_Dis;
            Current_Target_Pos = T_Cur_Pos;
        }
        // 前に決めた最短距離のターゲットよりも現在のターゲットの方が距離が短い
        // (今のターゲットの方が近い)
        if(T_Cur_Dis < Current_Target_Dis)
        {
            Current_Target_Dis = T_Cur_Dis;
            Current_Target_Pos = T_Cur_Pos;
        }
    }

    // 最短距離のターゲットがいる方向を計算してY軸回転する
    // vins様の記事を引用 引用元 https://zenn.dev/vins/articles/649a70dfe2ab17
    let t_direction = Current_Target_Pos.clone().sub(Cur_Pos); // これでターゲットへの方向が
    let t_angle = rad2deg(Math.atan2(t_direction.x, t_direction.z));
    let t_look_Rot = lookRotation(t_direction, new Vector3(0, t_angle, 0)); // 引数forwardにはターゲットへの方向、 upにはターゲットへのY軸方向ベクトル
    
    // 距離3いないなら通常の移動へ 2乗なので9
    if(Current_Target_Dis > 9)
    {
        $.setRotation($.getRotation().clone().slerp(t_look_Rot, 0.009)); //これターゲットの方向に向く
        // 移動するnormalizeは正規化。これで方向を保ったまま単位ベクトルにできる。はず。。
        t_direction.normalize().multiplyScalar(5 * deltatime);
    }
    $.setPosition(Cur_Pos.clone().add(new Vector3(0, 0, 5).multiplyScalar(1 * deltatime).applyQuaternion(lookRotation(directionZ, Vector3Up))));
});

これで、指定範囲外から出たら範囲内に戻るように出来ました。

もっとよりいい感じに

最短距離追跡。追跡をやめる。範囲外に出た場合に範囲内に戻る。
そこそこいい感じになってきましたが、まだ物足りないですね。
ということでもっと機能面を拡張していきましょう。
もっといい感じにしたいので、追加の機能を考えると以下の
ように2つ思い浮かびました。

  • 対象が一定高度以上で追尾して欲しい(紳士的な戦闘機が欲しい。)

  • ある程度の姿勢制御は欲しい。(現状はほぼ追跡機能に委ねてるけれど、追跡してない時でも姿勢維持をなんとなくして欲しい。。。

  • 対象との距離が一定以下の場合に追尾する。(現状は探知範囲が無限なので、限界を設定)

大体以下のような感じになりました。

// cluster上で動作するNPC戦闘機のスクリプト
// Parent Constraitで標的にくっつけた子オブジェクトを定義。
const Target_Objects = [
    $.subNode("Target_Pos_1"),
    $.subNode("Target_Pos_2"),
    $.subNode("Target_Pos_3"),
    $.subNode("Target_Pos_4")
];

// 回転に使う各種軸
const Xaxis = new Vector3(1, 0, 0);
const Yaxis = new Vector3(0, 1, 0);
const Zaxis = new Vector3(0, 0, 1);

const NewRot = new Quaternion(); // メモリリーク考慮(ほびわん様のスクリプトノート参考) URL https://scrapbox.io/hobione-note/ClusterScript%E4%BD%9C%E4%BE%8B
const Vector3Up = new Vector3(0, 1, 0);
const rad2deg = (rad) => (rad * 180) / Math.PI;
const WOP = $.subNode("World_Origin");
// https://answers.unity.com/questions/467614/what-is-the-source-code-of-quaternionlookrotation.html
// ねおりん様の記事より引用 URL https://zenn.dev/noir_neo/articles/09087a159d8479#%E6%A9%9F%E4%BD%93%E3%81%AE%E5%A7%BF%E5%8B%A2
const lookRotation = (forward, up) => {
    forward = forward.clone().normalize();
    const right = up.clone().cross(forward).normalize();
    up = forward.clone().cross(right);
  
    const m00 = right.x;
    const m01 = right.y;
    const m02 = right.z;
    const m10 = up.x;
    const m11 = up.y;
    const m12 = up.z;
    const m20 = forward.x;
    const m21 = forward.y;
    const m22 = forward.z;
  
    const num8 = (m00 + m11) + m22;
    if (num8 > 0)
    {
      var num = Math.sqrt(num8 + 1);
      const w = num * 0.5;
      num = 0.5 / num;
      const x = (m12 - m21) * num;
      const y = (m20 - m02) * num;
      const z = (m01 - m10) * num;
      return new Quaternion(x, y, z, w);
    }
    if ((m00 >= m11) && (m00 >= m22))
    {
      var num7 = Math.sqrt(((1 + m00) - m11) - m22);
      var num4 = 0.5 / num7;
      const x = 0.5 * num7;
      const y = (m01 + m10) * num4;
      const z = (m02 + m20) * num4;
      const w = (m12 - m21) * num4;
      return new Quaternion(x, y, z, w);
    }
    if (m11 > m22)
    {
      var num6 = Math.sqrt(((1 + m11) - m00) - m22);
      var num3 = 0.5 / num6;
      const x = (m10 + m01) * num3;
      const y = 0.5 * num6;
      const z = (m21 + m12) * num3;
      const w = (m20 - m02) * num3;
      return new Quaternion(x, y, z, w);
    }
    var num5 = Math.sqrt(((1 + m22) - m00) - m11);
    var num2 = 0.5 / num5;
    const x = (m20 + m02) * num2;
    const y = (m21 + m12) * num2;
    const z = 0.5 * num5;
    const w = (m01 - m10) * num2;
    return new Quaternion(x, y, z, w);
  };

$.onUpdate(deltatime => {
    if(!$.state.initialized) {
        $.state.initialized = true;
        $.state.PoseFlag = false; // 姿勢維持に使うフラグ
    }

    // 機体の進行方向にかかる力を取得する
    // 引用元 かせー様の記事 URL https://note.com/kasei_s/n/nf6a30f35a2b5
    const directionZ = new Vector3(0, 0, 1).applyQuaternion($.getRotation());
    const WO_Pos = WOP.getGlobalPosition();
    const Cur_Pos = $.getPosition(); // 現在の位置を取得する
    const Cur_Rot = $.getRotation().createEulerAngles();
    $.setStateCompat("this", "Current_Position_______ : ", Cur_Pos);
    
    // 以下の範囲を出た場合に、強制的に中央に戻す。
    if(Cur_Pos.x > 20 || Cur_Pos.x < -20 || Cur_Pos.y > 20 || Cur_Pos.y < 5 || Cur_Pos.z < -20 || Cur_Pos.z > 20)
    {
        let w_origin_direction = WO_Pos.clone().sub(Cur_Pos);
        let w_lk_rot = lookRotation(w_origin_direction, Vector3Up);
        $.setRotation($.getRotation().clone().slerp(w_lk_rot, 0.5));
        w_origin_direction.normalize().multiplyScalar(20 * deltatime);
        $.setPosition(Cur_Pos.add(w_origin_direction));
        return;
    }

    // 姿勢維持を行う。
    // 数値は適当。とりあえずX軸(ピッチ)は極力水平を保つように、ロール(Z軸)はなるべく自由にって感じ。
    if($.state.PoseFlag == true)
    {
        if(Cur_Rot.x > 30 && Cur_Rot.x <= 90)
        {
            $.setRotation($.getRotation().clone().multiply(NewRot.setFromAxisAngle(Xaxis, -30)));
        }
        if(Cur_Rot.x > 270 && Cur_Rot.x <= 350)
        {
            $.setRotation($.getRotation().clone().multiply(NewRot.setFromAxisAngle(Xaxis, 30)));
        }
        if(Cur_Rot.z > 250 && Cur_Rot.z <= 350)
        {
            $.setRotation($.getRotation().clone().multiply(NewRot.setFromAxisAngle(Zaxis, 20)));
        }
        if(Cur_Rot.z > 40 && Cur_Rot.z <= 100)
        {
            $.setRotation($.getRotation().clone().multiply(NewRot.setFromAxisAngle(Zaxis, -20)));
        }
        if(Cur_Rot.z > 150 && Cur_Rot.z < 240)
        {
            $.setRotation($.getRotation().clone().multiply(NewRot.setFromAxisAngle(Zaxis, 60)));
        }
    }

    let Current_Target_Dis = null; // 現在一番近くのターゲットとの距離
    let Current_Target_Pos = null; // 現在一番近くのターゲットの座標(XYZ)
    let C_Index = null;
    // lengthはjavascriptの配列のプロパティ
    // ほびわんさんのClusterScript作例ノートより
    // https://scrapbox.io/hobione-note/ClusterScript%E4%BD%9C%E4%BE%8B
    for(let i = 0; i < Target_Objects.length; i++)
    {
        let T_Cur_Pos = Target_Objects[i].getGlobalPosition().clone(); // ベータ機能。グローバル座標を取得する
        let T_Cur_Dis = T_Cur_Pos.clone().sub(Cur_Pos).lengthSq(); // length()と違い、2乗の計算を使わないため、早いらしい?
        // 現在一番近くのターゲットがまだ定まっていない
        if(Current_Target_Dis == null) {
            Current_Target_Dis = T_Cur_Dis;
            Current_Target_Pos = T_Cur_Pos;
        }
        // 前に決めた最短距離のターゲットよりも現在のターゲットの方が距離が短い
        // (今のターゲットの方が近い)
        if(T_Cur_Dis < Current_Target_Dis)
        {
            Current_Target_Dis = T_Cur_Dis;
            Current_Target_Pos = T_Cur_Pos;
        }
    }

    // 最短距離のターゲットがいる方向を計算してY軸回転する
    // vins様の記事を引用 引用元 https://zenn.dev/vins/articles/649a70dfe2ab17
    let t_direction = Current_Target_Pos.clone().sub(Cur_Pos); // これでターゲットへの方向が
    let t_angle = rad2deg(Math.atan2(t_direction.x, t_direction.z));
    let t_look_Rot = lookRotation(t_direction, new Vector3(0, t_angle, 0)); // 引数forwardにはターゲットへの方向、 upにはターゲットへのY軸方向ベクトル
    
    // 距離15m以内、かつ距離3mより近くない
    if((Current_Target_Pos.y > 5) && Current_Target_Dis > 9 && Current_Target_Dis < 225)
    {
        $.state.PoseFlag = false;
        $.setRotation($.getRotation().clone().slerp(t_look_Rot, 0.009)); //これターゲットの方向に向く
        // 移動するnormalizeは正規化。これで方向を保ったまま単位ベクトルにできる。はず。。
        t_direction.normalize().multiplyScalar(5 * deltatime);
    }
    else
    {
        $.state.PoseFlag = true;
    }
    $.setPosition(Cur_Pos.clone().add(new Vector3(0, 0, 5).multiplyScalar(1 * deltatime).applyQuaternion(lookRotation(directionZ, Vector3Up))));
});

追加の部分として、回転に使う各種軸を定義したベクトル。
Xaxis, Yaxis, Zaxisを新たに追加しました。
これは姿勢維持の時に実行するsetFromAxisAngleで使います。
(参考元。ほびわん様のスクリプトノートから、感謝です!https://scrapbox.io/hobione-note/ClusterScript%E4%BD%9C%E4%BE%8B
また姿勢維持に入るためにPoseFlagを追加しました。
これで追跡中には発動しないようにしています。
姿勢維持の各種数字は適当ですので、まあ適当に弄ってください。
ターゲットが一定高度以上と一定距離以下で追尾するようにするには
最短距離ターゲットのベクトルのY成分からと、距離から判定しました。
自機の高度でもいいですが、せっかくなので、相手の高度に合わせてみます。

もっともっとよりいい感じっぽく

さて、わりとそこそこいい感じですが、やっぱり戦闘機といったらドッグファイトをして欲しいですよね。
そこで、ドッグファイトをやってくれるように作っていきましょう。

ドッグファイトをやれるには

ドッグファイトは相手の戦闘機と互いに後ろを取るように動くものっぽいです。
ここまで作ってきたNPC戦闘機は最短距離のターゲットを追跡してくれますが、ただ追跡するだけで、ターゲットが例えば後方にいたり、前方にいたり、左右にいたりとかはわかりません。(まあ探知範囲内にいれば後ろだろうが、左右だろうがググっと追尾して正面に捉えてしまいますが、とりあえず置いておきます。。。)
そこで、凄く疑似的にはありますが、このNPC戦闘機にそれっぽく視界を作ってあげましょう。
注意事項です。:(これから上げるターゲットの視界判定はただでさえ独断と偏見で調べて作り上げてる本機の中でもとくに独断色が強いものです。
正直間違ってる可能性が高いかもです。それでもよければどうぞ。)

視界を作ろう。

さてNPCの視界を作ろうといっても魂も肉体もないような子にどうやって作ったらいいのか途方にくれますが、
大変ありがたいことに以下の先人様方の記事を参考にするとどうにか作れそうです。この場を借りてありがとうございます!

こちらの記事様方の紹介されてるソースコードを自己流ですが、ClusterScriptに書き直していくといい感じにできるはずです。
ただ自己流で見よう見まねで書いているので間違ってるかもしれません。
そこはご了承ください。(自分のレベルの低さよ…。)

ドッグファイトのための戦闘起動(マニューバ)を作る。

ドッグファイトを行うために、視界判定、そしてピッチとロールを使った戦闘起動(マニューバ)を行うための機能を作ります。
また、一定時間たったら緊急離脱するようにもします。
ヨーは簡単化のために実装はしないです。許して!

// cluster上で動作するNPC戦闘機のスクリプト
// Parent Constraitで標的にくっつけた子オブジェクトを定義。
const Target_Objects = [
    $.subNode("Target_Pos_1"),
    $.subNode("Target_Pos_2"),
    $.subNode("Target_Pos_3"),
    $.subNode("Target_Pos_4")
];
// 回転に使う各種軸
const Xaxis = new Vector3(1, 0, 0);
const Yaxis = new Vector3(0, 1, 0);
const Zaxis = new Vector3(0, 0, 1);
const NewRot = new Quaternion(); // メモリリーク考慮(ほびわん様のスクリプトノート参考) URL https://scrapbox.io/hobione-note/ClusterScript%E4%BD%9C%E4%BE%8B
const Vector3Up = new Vector3(0, 1, 0);
const rad2deg = (rad) => (rad * 180) / Math.PI;
const WOP = $.subNode("World_Origin");
const AvoiTimeLength = 10; // 緊急離脱までの時間
const CombatTimeLength = 5; // 緊急離脱から戦闘行動に入るまでの時間
const RightLeftSideObject = $.subNode("RightLeftSide"); // 左右判定のオブジェクト

// ランダムな値を返す関数
// https://note.com/ab_masap/n/nfeff0fdc89b1 あばっしゅ様より引用
const getRandomValue = (max) =>  {
    return Math.floor( Math.random() * max);
}
// ドッグファイトのための戦闘起動を行う。
const Combat_Func_Vec_Maneuver = (t_pos, t_index, direction_z, c_deltatime) => {
    // 後ろ前の判定
    let vec1 = t_pos.clone().sub($.getPosition()).normalize().dot(direction_z);
    // ピッチ・ロールのパラメータ
    let maneuver_random_value_1 = getRandomValue(-5);
    let maneuver_random_value_2 = getRandomValue(5);
    let rl_pos = RightLeftSideObject.getPosition();
    let ab_vector = rl_pos.clone().sub($.getPosition());
    let acRigght = t_pos.clone().sub($.getPosition()).cross(Vector3Up);
    let cross = new Vector3(ab_vector.x, 0, ab_vector.z).normalize().dot(new Vector3(acRigght.x, 0, acRigght.z).normalize());
    
    if(vec1 <= 0)
    {
        // 敵機が後ろ
        $.setRotation($.getRotation().clone().multiply(NewRot.setFromEulerAngles(new Vector3(-0.6, 0, 0).multiplyScalar(1 * c_deltatime))));
    }
    else
    {
        // 敵機が前
        $.setRotation($.getRotation().clone().multiply(NewRot.setFromEulerAngles(new Vector3(maneuver_random_value_1, 0, 0).multiplyScalar(1 * c_deltatime))));
        $.setRotation($.getRotation().clone().multiply(NewRot.setFromEulerAngles(new Vector3(0, 0, maneuver_random_value_2).multiplyScalar(1 * c_deltatime))));
    }
    if(cross < 0)
    {
        // 自機から見て右
        $.setRotation($.getRotation().clone().multiply(NewRot.setFromAxisAngle(Zaxis, maneuver_random_value_2)));
        $.setRotation($.getRotation().clone().multiply(NewRot.setFromAxisAngle(Xaxis, maneuver_random_value_1)));
    }
    else
    {
        // 自機から見て左
        $.setRotation($.getRotation().clone().multiply(NewRot.setFromAxisAngle(Zaxis, maneuver_random_value_1)));
        $.setRotation($.getRotation().clone().multiply(NewRot.setFromAxisAngle(Xaxis, maneuver_random_value_2)));
    }
}


// https://answers.unity.com/questions/467614/what-is-the-source-code-of-quaternionlookrotation.html
// ねおりん様の記事より引用 URL https://zenn.dev/noir_neo/articles/09087a159d8479#%E6%A9%9F%E4%BD%93%E3%81%AE%E5%A7%BF%E5%8B%A2
const lookRotation = (forward, up) => {
    forward = forward.clone().normalize();
    const right = up.clone().cross(forward).normalize();
    up = forward.clone().cross(right);
  
    const m00 = right.x;
    const m01 = right.y;
    const m02 = right.z;
    const m10 = up.x;
    const m11 = up.y;
    const m12 = up.z;
    const m20 = forward.x;
    const m21 = forward.y;
    const m22 = forward.z;
  
    const num8 = (m00 + m11) + m22;
    if (num8 > 0)
    {
      var num = Math.sqrt(num8 + 1);
      const w = num * 0.5;
      num = 0.5 / num;
      const x = (m12 - m21) * num;
      const y = (m20 - m02) * num;
      const z = (m01 - m10) * num;
      return new Quaternion(x, y, z, w);
    }
    if ((m00 >= m11) && (m00 >= m22))
    {
      var num7 = Math.sqrt(((1 + m00) - m11) - m22);
      var num4 = 0.5 / num7;
      const x = 0.5 * num7;
      const y = (m01 + m10) * num4;
      const z = (m02 + m20) * num4;
      const w = (m12 - m21) * num4;
      return new Quaternion(x, y, z, w);
    }
    if (m11 > m22)
    {
      var num6 = Math.sqrt(((1 + m11) - m00) - m22);
      var num3 = 0.5 / num6;
      const x = (m10 + m01) * num3;
      const y = 0.5 * num6;
      const z = (m21 + m12) * num3;
      const w = (m20 - m02) * num3;
      return new Quaternion(x, y, z, w);
    }
    var num5 = Math.sqrt(((1 + m22) - m00) - m11);
    var num2 = 0.5 / num5;
    const x = (m20 + m02) * num2;
    const y = (m21 + m12) * num2;
    const z = 0.5 * num5;
    const w = (m01 - m10) * num2;
    return new Quaternion(x, y, z, w);
  };

$.onUpdate(deltatime => {
    if(!$.state.initialized) {
        $.state.initialized = true;
        $.state.PoseFlag = false; // 姿勢維持に使うフラグ
        $.state.CombatFlag = false; // 戦闘フラグ
        $.state.AvoiFlag = false; // 緊急離脱フラグ
        $.state.AvoiTime = 0.0; // 緊急離脱時間
        $.state.CombatTimes = 0.0;
    }

    // 機体の進行方向にかかる力を取得する
    // 引用元 かせー様の記事 URL https://note.com/kasei_s/n/nf6a30f35a2b5
    const directionZ = new Vector3(0, 0, 1).applyQuaternion($.getRotation());
    const WO_Pos = WOP.getGlobalPosition();
    const Cur_Pos = $.getPosition(); // 現在の位置を取得する
    const Cur_Rot = $.getRotation().createEulerAngles();
    $.setStateCompat("this", "Current_Position_______ : ", Cur_Pos);
    
    // 以下の範囲を出た場合に、強制的に中央に戻す。
    if(Cur_Pos.x > 20 || Cur_Pos.x < -20 || Cur_Pos.y > 20 || Cur_Pos.y < 5 || Cur_Pos.z < -20 || Cur_Pos.z > 20)
    {
        let w_origin_direction = WO_Pos.clone().sub(Cur_Pos);
        let w_lk_rot = lookRotation(w_origin_direction, Vector3Up);
        $.setRotation($.getRotation().clone().slerp(w_lk_rot, 0.5));
        w_origin_direction.normalize().multiplyScalar(20 * deltatime);
        $.setPosition(Cur_Pos.add(w_origin_direction));
        return;
    }

    // 姿勢維持を行う。
    // 数値は適当。とりあえずX軸(ピッチ)は極力水平を保つように、ロール(Z軸)はなるべく自由にって感じ。
    if($.state.PoseFlag == true)
    {
        if(Cur_Rot.x > 30 && Cur_Rot.x <= 90)
        {
            $.setRotation($.getRotation().clone().multiply(NewRot.setFromAxisAngle(Xaxis, -30)));
        }
        if(Cur_Rot.x > 270 && Cur_Rot.x <= 350)
        {
            $.setRotation($.getRotation().clone().multiply(NewRot.setFromAxisAngle(Xaxis, 30)));
        }
        if(Cur_Rot.z > 250 && Cur_Rot.z <= 350)
        {
            $.setRotation($.getRotation().clone().multiply(NewRot.setFromAxisAngle(Zaxis, 20)));
        }
        if(Cur_Rot.z > 40 && Cur_Rot.z <= 100)
        {
            $.setRotation($.getRotation().clone().multiply(NewRot.setFromAxisAngle(Zaxis, -20)));
        }
        if(Cur_Rot.z > 150 && Cur_Rot.z < 240)
        {
            $.setRotation($.getRotation().clone().multiply(NewRot.setFromAxisAngle(Zaxis, 60)));
        }
    }

    let Current_Target_Dis = null; // 現在一番近くのターゲットとの距離
    let Current_Target_Pos = null; // 現在一番近くのターゲットの座標(XYZ)
    let C_Index = null;
    // lengthはjavascriptの配列のプロパティ
    // ほびわんさんのClusterScript作例ノートより
    // https://scrapbox.io/hobione-note/ClusterScript%E4%BD%9C%E4%BE%8B
    for(let i = 0; i < Target_Objects.length; i++)
    {
        let T_Cur_Pos = Target_Objects[i].getGlobalPosition().clone(); // ベータ機能。グローバル座標を取得する
        let T_Cur_Dis = T_Cur_Pos.clone().sub(Cur_Pos).lengthSq(); // length()と違い、2乗の計算を使わないため、早いらしい?
        // 現在一番近くのターゲットがまだ定まっていない
        if(Current_Target_Dis == null) {
            Current_Target_Dis = T_Cur_Dis;
            Current_Target_Pos = T_Cur_Pos;
            C_Index = i;
        }
        // 前に決めた最短距離のターゲットよりも現在のターゲットの方が距離が短い
        // (今のターゲットの方が近い)
        if(T_Cur_Dis < Current_Target_Dis)
        {
            Current_Target_Dis = T_Cur_Dis;
            Current_Target_Pos = T_Cur_Pos;
            C_Index = i;
        }
    }

    // 最短距離のターゲットがいる方向を計算してY軸回転する
    // vins様の記事を引用 引用元 https://zenn.dev/vins/articles/649a70dfe2ab17
    let t_direction = Current_Target_Pos.clone().sub(Cur_Pos); // これでターゲットへの方向が
    let t_angle = rad2deg(Math.atan2(t_direction.x, t_direction.z));
    let t_look_Rot = lookRotation(t_direction, new Vector3(0, t_angle, 0)); // 引数forwardにはターゲットへの方向、 upにはターゲットへのY軸方向ベクトル
    
    // 距離15m以内、かつ距離3mより近くない
    if((Current_Target_Pos.y > 5) &&  Current_Target_Dis < 225)
    {
        $.state.PoseFlag = false;
        $.state.CombatFlag = true;
    }
    else
    {
        $.state.PoseFlag = true;
        $.state.CombatFlag = false;
    }
    if($.state.CombatFlag == true && $.state.AvoiFlag == false)
    {
        $.state.AvoiTime += deltatime; // 緊急離脱までの経過時間を考える
        if($.state.AvoiTime > AvoiTimeLength)
        {
            $.state.AvoiTime = 0.0;
            $.state.AvoiFlag = true;
            $.state.CombatFlag = false;
        }
        else
        {
            $.state.AvoiFlag = false;
        }
        if(Current_Target_Dis > 36)
        {
            $.setRotation($.getRotation().clone().slerp(t_look_Rot, 0.009)); //これターゲットの方向に向く
            // 移動するnormalizeは正規化。これで方向を保ったまま単位ベクトルにできる。はず。。
            t_direction.normalize().multiplyScalar(5 * deltatime);
        }
        else
        {
            Combat_Func_Vec_Maneuver(Current_Target_Pos, C_Index, directionZ, deltatime);
        }
    } else if($.state.AvoiFlag == true)
    {
        // 再び戦闘行動に入る準備
        $.state.CombatFlag = false;
        $.state.CombatTimes += deltatime;
        if($.state.CombatTimes > CombatTimeLength) {
            $.state.CombatTimes = 0.0;
            $.state.AvoiFlag = false;
        }
    }
    $.setPosition(Cur_Pos.clone().add(new Vector3(0, 0, 5).multiplyScalar(1 * deltatime).applyQuaternion(lookRotation(directionZ, Vector3Up))));
});

また「NPC_AUF_XX_01」コンポーネント下の「RightLeftSide」オブジェクトを+Z方向に100移動させておいてください。
(値はおそらく適当でもいいかも?よくわからん…。)

こんな感じ

色々追加しましたが、新たに「Combat_Func_Vec_Maneuver」関数を作成しました。
この関数は最短距離のターゲットのベクトルとそのターゲット配列インデックス、経過時間を受け取ります。
配列インデックスはこの後使います。
受け取った引数をもとに、先ほどの記事様を参考にした視界判定を行い各種ロールとピッチを行います。
ピッチとロールは 変数maneuver_random_value_1とmaneuver_random_value_2でランダムな値を取って行います。
機動の調整はこのランダム関数の引数を弄ってもらえればできると思います。
ランダムな値を返す関数は「あばっしゅ様」の記事を参考に作りました。
ありがとうございます!

また、戦闘行動に入るフラグ、緊急離脱するフラグ、緊急離脱に入る時間、
緊急離脱した後から再び戦闘行動に入る時間の処理を追加しました。
これで実行すると多少おかしいところはあれど、まあまあいい感じの機動を描いてくれるようになりました。
またターゲットとの誘導を切る距離を3mから6mに伸ばしました。
(ちょっと短か過ぎた感じがしたので)

攻撃して欲しい。

戦闘機といったらやっぱりミサイルで攻撃して欲しいですよね。
ということでミサイルを作ります。

ホーミングするミサイルを作ろう。

さて、ここでは生成する弾にホーミング性能を持たせることは
筆者は作れないので、あらかじめアクティブな弾にホーミング性能を
持たせてアニメーションで飛ばすものを作ります。
まず、ミサイルのオブジェクトを作ります。
「NPC_AUFXX_01」と同じ階層に空のゲームオブジェクトを作って「Missile_1」っていう名前にします。

つぎにこのオブジェクトに「Parent Constrait」、「Aim Constrait」
「Movable Item」「Animator」コンポーネントをそれぞれ追加します。
一応「Capsule Colider」もつけておきましょう。
そしたら、「Parent Constrait」のソースに「NPC_AUFXX_01」を
「Aim Constrait」のソースに「Target_0」~「Target_3」までの
各オブジェクトを設定し、ゼロで初期化したあと、ソースの重みを
全て0に設定してください。
Target_0と0から始めてください。
また、Rigidbodyの重力を使用もOFFに設定してください。
アニメーターにはルートモーションを適用をONにもしておいてください。
Aim ConstraitはOFFにしておいてください。

さて、ここからアニメーションを作っていきます。
アニメーションの詳しい作り方に関しては他の記事様を当たってください。
ということで、作るアニメーションをリストに見ます。
以下のアニメーションを作ります。

  • Aim Constraitのソースのウェイトを標的に合わせて1にする

  • Parent ConstraitをOFFにする

  • Aim ConstraitをONにする

  • ミサイルを飛ばす(移動させるアニメーションを開始させる)

  • 衝突した時のアニメーション(パーティクルでも表示させる?)

  • Parent ConstraitをONにする

だいたいこんな感じで作っていきます。
1つずつサブノードを使って作っていきます。
以下のような感じです。
これをターゲットの数分作っていきます。

こんかいは4つのターゲットがあるので4つ分作ります。

これを4つ分です

またターゲットロックのアニメーションとParent Constrait ONのアニメーション以外のWrite Default はOFFにしてください。

Write DefaultsはOFFにする

こうすることで、前のアニメーションの遷移の結果を維持しつつ重ねて適用できるような感じになります。
また各種遷移フラグとset Animator Value Gimmickを設定します。

こんな感じ

ミサイルを飛ばすスクリプト

ミサイルを飛ばすスクリプトを書きます。
$.setStateCompatでアニメーションフラグを管理して飛ばします。
また「NPC_AUFXX_01」以下にある子オブジェクトの
「Missile_Pos_1」にParent Constraitを付けて、ソースを「Missile_1」に設定しておきます。これでスクリプト内でミサイルの追跡ができます。
このミサイル追跡と最短距離のターゲット追跡で距離を使った疑似的な当たり判定ができます。
(まれに機能しない時があるけど、、、なんでなんでしょう・・・??)
スクリプトは以下です。

// cluster上で動作するNPC戦闘機のスクリプト
// Parent Constraitで標的にくっつけた子オブジェクトを定義。
const Target_Objects = [
    $.subNode("Target_Pos_0"),
    $.subNode("Target_Pos_1"),
    $.subNode("Target_Pos_2"),
    $.subNode("Target_Pos_3")
];
// 回転に使う各種軸
const Xaxis = new Vector3(1, 0, 0);
const Yaxis = new Vector3(0, 1, 0);
const Zaxis = new Vector3(0, 0, 1);
const NewRot = new Quaternion(); // メモリリーク考慮(ほびわん様のスクリプトノート参考) URL https://scrapbox.io/hobione-note/ClusterScript%E4%BD%9C%E4%BE%8B
const Vector3Up = new Vector3(0, 1, 0);
const rad2deg = (rad) => (rad * 180) / Math.PI;
const WOP = $.subNode("World_Origin");
const Missile_1_Pos = $.subNode("Missile_Pos_1");
const AvoiTimeLength = 10; // 緊急離脱までの時間
const CombatTimeLength = 5; // 緊急離脱から戦闘行動に入るまでの時間
const RightLeftSideObject = $.subNode("RightLeftSide"); // 左右判定のオブジェクト
const Miisile_Distance = 60;

// ランダムな値を返す関数
// https://note.com/ab_masap/n/nfeff0fdc89b1 あばっしゅ様より引用
const getRandomValue = (max) =>  {
    return Math.floor( Math.random() * max);
}
const Missile_Fire_1 = (Index, Flag) => {
    if(Flag == true)
    {
            if($.state.Target_Fire == false) {
                $.state.Target_Index = Index;
                $.state.Target_Fire = true;
                $.setStateCompat("this", "Target_LOCK_"+Index.toString(), true);
                $.setStateCompat("this", "Missile_Fire", true);
            }
    }
    else
    {
        $.setStateCompat("this", "Missile_Fire", false);
        $.setStateCompat("this", "Target_LOCK_0", false);
        $.setStateCompat("this", "Target_LOCK_1", false);
        $.setStateCompat("this", "Target_LOCK_2", false);
        $.setStateCompat("this", "Target_LOCK_3", false);
    }
}
// ドッグファイトのための戦闘起動を行う。
const Combat_Func_Vec_Maneuver = (t_pos, t_index, direction_z, c_deltatime) => {
    // 後ろ前の判定
    let vec1 = t_pos.clone().sub($.getPosition()).normalize().dot(direction_z);
    // ピッチ・ロールのパラメータ
    let maneuver_random_value_1 = getRandomValue(-5);
    let maneuver_random_value_2 = getRandomValue(5);
    let rl_pos = RightLeftSideObject.getPosition();
    let ab_vector = rl_pos.clone().sub($.getPosition());
    let acRigght = t_pos.clone().sub($.getPosition()).cross(Vector3Up);
    let cross = new Vector3(ab_vector.x, 0, ab_vector.z).normalize().dot(new Vector3(acRigght.x, 0, acRigght.z).normalize());
    
    if(vec1 <= 0)
    {
        // 敵機が後ろ
        $.setRotation($.getRotation().clone().multiply(NewRot.setFromEulerAngles(new Vector3(-0.6, 0, 0).multiplyScalar(1 * c_deltatime))));
        Missile_Fire_1(t_index, true);
    }
    else
    {
        // 敵機が前
        $.setRotation($.getRotation().clone().multiply(NewRot.setFromEulerAngles(new Vector3(maneuver_random_value_1, 0, 0).multiplyScalar(1 * c_deltatime))));
        $.setRotation($.getRotation().clone().multiply(NewRot.setFromEulerAngles(new Vector3(0, 0, maneuver_random_value_2).multiplyScalar(1 * c_deltatime))));
        Missile_Fire_1(t_index, true);
    }
    if(cross < 0)
    {
        // 自機から見て右
        $.setRotation($.getRotation().clone().multiply(NewRot.setFromAxisAngle(Zaxis, maneuver_random_value_2)));
        $.setRotation($.getRotation().clone().multiply(NewRot.setFromAxisAngle(Xaxis, maneuver_random_value_1)));
        Missile_Fire_1(t_index, true);
    }
    else
    {
        // 自機から見て左
        $.setRotation($.getRotation().clone().multiply(NewRot.setFromAxisAngle(Zaxis, maneuver_random_value_1)));
        $.setRotation($.getRotation().clone().multiply(NewRot.setFromAxisAngle(Xaxis, maneuver_random_value_2)));
        Missile_Fire_1(t_index, true);
    }
}


// https://answers.unity.com/questions/467614/what-is-the-source-code-of-quaternionlookrotation.html
// ねおりん様の記事より引用 URL https://zenn.dev/noir_neo/articles/09087a159d8479#%E6%A9%9F%E4%BD%93%E3%81%AE%E5%A7%BF%E5%8B%A2
const lookRotation = (forward, up) => {
    forward = forward.clone().normalize();
    const right = up.clone().cross(forward).normalize();
    up = forward.clone().cross(right);
  
    const m00 = right.x;
    const m01 = right.y;
    const m02 = right.z;
    const m10 = up.x;
    const m11 = up.y;
    const m12 = up.z;
    const m20 = forward.x;
    const m21 = forward.y;
    const m22 = forward.z;
  
    const num8 = (m00 + m11) + m22;
    if (num8 > 0)
    {
      var num = Math.sqrt(num8 + 1);
      const w = num * 0.5;
      num = 0.5 / num;
      const x = (m12 - m21) * num;
      const y = (m20 - m02) * num;
      const z = (m01 - m10) * num;
      return new Quaternion(x, y, z, w);
    }
    if ((m00 >= m11) && (m00 >= m22))
    {
      var num7 = Math.sqrt(((1 + m00) - m11) - m22);
      var num4 = 0.5 / num7;
      const x = 0.5 * num7;
      const y = (m01 + m10) * num4;
      const z = (m02 + m20) * num4;
      const w = (m12 - m21) * num4;
      return new Quaternion(x, y, z, w);
    }
    if (m11 > m22)
    {
      var num6 = Math.sqrt(((1 + m11) - m00) - m22);
      var num3 = 0.5 / num6;
      const x = (m10 + m01) * num3;
      const y = 0.5 * num6;
      const z = (m21 + m12) * num3;
      const w = (m20 - m02) * num3;
      return new Quaternion(x, y, z, w);
    }
    var num5 = Math.sqrt(((1 + m22) - m00) - m11);
    var num2 = 0.5 / num5;
    const x = (m20 + m02) * num2;
    const y = (m21 + m12) * num2;
    const z = 0.5 * num5;
    const w = (m01 - m10) * num2;
    return new Quaternion(x, y, z, w);
  };

$.onUpdate(deltatime => {
    if(!$.state.initialized) {
        $.state.initialized = true;
        $.state.PoseFlag = false; // 姿勢維持に使うフラグ
        $.state.CombatFlag = false; // 戦闘フラグ
        $.state.AvoiFlag = false; // 緊急離脱フラグ
        $.state.AvoiTime = 0.0; // 緊急離脱時間
        $.state.CombatTimes = 0.0;
        $.state.Target_Index = null; // ミサイルの標的
        $.state.Target_Fire = false; // ミサイル発射のフラグ
    }

    // 機体の進行方向にかかる力を取得する
    // 引用元 かせー様の記事 URL https://note.com/kasei_s/n/nf6a30f35a2b5
    const directionZ = new Vector3(0, 0, 1).applyQuaternion($.getRotation());
    const WO_Pos = WOP.getGlobalPosition();
    const Cur_Pos = $.getPosition(); // 現在の位置を取得する
    const Cur_Rot = $.getRotation().createEulerAngles();
    $.setStateCompat("this", "Current_Position_______ : ", Cur_Pos);
    
    // 以下の範囲を出た場合に、強制的に中央に戻す。
    if(Cur_Pos.x > 20 || Cur_Pos.x < -20 || Cur_Pos.y > 20 || Cur_Pos.y < 5 || Cur_Pos.z < -20 || Cur_Pos.z > 20)
    {
        let w_origin_direction = WO_Pos.clone().sub(Cur_Pos);
        let w_lk_rot = lookRotation(w_origin_direction, Vector3Up);
        $.setRotation($.getRotation().clone().slerp(w_lk_rot, 0.5));
        w_origin_direction.normalize().multiplyScalar(20 * deltatime);
        $.setPosition(Cur_Pos.add(w_origin_direction));
        return;
    }

    // 姿勢維持を行う。
    // 数値は適当。とりあえずX軸(ピッチ)は極力水平を保つように、ロール(Z軸)はなるべく自由にって感じ。
    if($.state.PoseFlag == true)
    {
        if(Cur_Rot.x > 30 && Cur_Rot.x <= 90)
        {
            $.setRotation($.getRotation().clone().multiply(NewRot.setFromAxisAngle(Xaxis, -30)));
        }
        if(Cur_Rot.x > 270 && Cur_Rot.x <= 350)
        {
            $.setRotation($.getRotation().clone().multiply(NewRot.setFromAxisAngle(Xaxis, 30)));
        }
        if(Cur_Rot.z > 250 && Cur_Rot.z <= 350)
        {
            $.setRotation($.getRotation().clone().multiply(NewRot.setFromAxisAngle(Zaxis, 20)));
        }
        if(Cur_Rot.z > 40 && Cur_Rot.z <= 100)
        {
            $.setRotation($.getRotation().clone().multiply(NewRot.setFromAxisAngle(Zaxis, -20)));
        }
        if(Cur_Rot.z > 150 && Cur_Rot.z < 240)
        {
            $.setRotation($.getRotation().clone().multiply(NewRot.setFromAxisAngle(Zaxis, 60)));
        }
    }

    let Current_Target_Dis = null; // 現在一番近くのターゲットとの距離
    let Current_Target_Pos = null; // 現在一番近くのターゲットの座標(XYZ)
    let C_Index = null;
    // lengthはjavascriptの配列のプロパティ
    // ほびわんさんのClusterScript作例ノートより
    // https://scrapbox.io/hobione-note/ClusterScript%E4%BD%9C%E4%BE%8B
    for(let i = 0; i < Target_Objects.length; i++)
    {
        let T_Cur_Pos = Target_Objects[i].getGlobalPosition().clone(); // ベータ機能。グローバル座標を取得する
        let T_Cur_Dis = T_Cur_Pos.clone().sub(Cur_Pos).lengthSq(); // length()と違い、2乗の計算を使わないため、早いらしい?
        // 現在一番近くのターゲットがまだ定まっていない
        if(Current_Target_Dis == null) {
            Current_Target_Dis = T_Cur_Dis;
            Current_Target_Pos = T_Cur_Pos;
            C_index = i;
        }
        // 前に決めた最短距離のターゲットよりも現在のターゲットの方が距離が短い
        // (今のターゲットの方が近い)
        if(T_Cur_Dis < Current_Target_Dis)
        {
            Current_Target_Dis = T_Cur_Dis;
            Current_Target_Pos = T_Cur_Pos;
            C_Index = i;
        }
    }

    // 最短距離のターゲットがいる方向を計算してY軸回転する
    // vins様の記事を引用 引用元 https://zenn.dev/vins/articles/649a70dfe2ab17
    let t_direction = Current_Target_Pos.clone().sub(Cur_Pos); // これでターゲットへの方向が
    let t_angle = rad2deg(Math.atan2(t_direction.x, t_direction.z));
    let t_look_Rot = lookRotation(t_direction, new Vector3(0, t_angle, 0)); // 引数forwardにはターゲットへの方向、 upにはターゲットへのY軸方向ベクトル
    
    // 距離15m以内、かつ距離3mより近くない
    if((Current_Target_Pos.y > 5) &&  Current_Target_Dis < 225)
    {
        $.state.PoseFlag = false;
        $.state.CombatFlag = true;
    }
    else
    {
        $.state.PoseFlag = true;
        $.state.CombatFlag = false;
    }
    if($.state.CombatFlag == true && $.state.AvoiFlag == false)
    {
        $.state.AvoiTime += deltatime; // 緊急離脱までの経過時間を考える
        if($.state.AvoiTime > AvoiTimeLength)
        {
            $.state.AvoiTime = 0.0;
            $.state.AvoiFlag = true;
            $.state.CombatFlag = false;
            Missile_Fire_1(C_Index, false);
        }
        else
        {
            $.state.AvoiFlag = false;
        }
        if(Current_Target_Dis > 36)
        {
            $.setRotation($.getRotation().clone().slerp(t_look_Rot, 0.009)); //これターゲットの方向に向く
            // 移動するnormalizeは正規化。これで方向を保ったまま単位ベクトルにできる。はず。。
            t_direction.normalize().multiplyScalar(5 * deltatime);
            Missile_Fire_1(C_Index, true); // ミサイル発射
        }
        else
        {
            Combat_Func_Vec_Maneuver(Current_Target_Pos, C_Index, directionZ, deltatime);
        }
    } else if($.state.AvoiFlag == true)
    {
        // 再び戦闘行動に入る準備
        $.state.CombatFlag = false;
        $.state.CombatTimes += deltatime;
        Missile_Fire_1(C_Index, false);
        if($.state.CombatTimes > CombatTimeLength) {
            $.state.CombatTimes = 0.0;
            $.state.AvoiFlag = false;
        }
    }

    if($.state.Target_Fire == true && $.state.Target_Index != null)
    {
        $.setStateCompat("this", "Target_Index________________ : ", $.state.Target_Index);
        let Mis_Dis = Target_Objects[$.state.Target_Index].getGlobalPosition().clone().sub(Missile_1_Pos.getGlobalPosition()).lengthSq();
        if(Mis_Dis < 9)
        {
            $.state.Target_Fire = false;
            $.setStateCompat("this", "Impact", true);
        }
        else
        {
            $.setStateCompat("this", "Impact", false);
        }
    }
    $.setPosition(Cur_Pos.clone().add(new Vector3(0, 0, 5).multiplyScalar(1 * deltatime).applyQuaternion(lookRotation(directionZ, Vector3Up))));
});

Missile_Fire関数でミサイル発射フラグの管理を行っています。
処理の最後の方に発射したミサイルのターゲットととの距離判定を行い、
距離3m以下で爆発するようにしています。

とりあえずそれっぽく動くものはできた。

ここまでで、初歩的ながらも自分で行動し、相手を定めてロックオンして
ミサイルを発射してくれるNPC戦闘機っぽいのが出来ました。
正直素人実装なので、機動がガクガクとかミサイルがどっか飛んでいったり
もしますが、とりあえずそれっぽいのが出来たのでヨシ!とします。
これぞ素人クオリティさね!

終わりに

なんでNPCの無人戦闘機なんか作ってドッグファイトなんかしたいのか今更ながら話しますと、
clusterでは他者と戦闘機でドッグファイトをしたら同期でカクカクになるとか(負荷軽減のためこれはもうどうしようもないっぽい感じではあります)、筆者の幼少期にとあるRPGのボスを友達と試行錯誤して協力して、
ともに打倒した時の思い出と快感とか(あの時の達成感は凄かった。。。)etc。。。。と
理由は色々あるけど、やはりロマンです(ここ大事)
考えてみてください!!!!
仮想世界の空を駆け巡る心を持たぬ強大な敵に立ち向かうプレイヤ―(人間たち)、、、ロマンだとは思いませんか!
。。。はい、そんな感じです。
まあ人がメインコンテンツのバーチャルSNSで交流することが大事で楽しいのは自分もわかります(このアドカレだって数多の人たちとの交流によって書いています。ありがたいものです。)
。けれど、その中でこういう変わったものがあってもいいんじゃないでしょうか。わかんないけど!
以上です!終わり!
また本記事で作成したNPC戦闘機のUnityパッケージを置いておきます。
Prefabディレクトリ以下のファイルをヒエラルキーにドラックアンドドロップして、各種設定をしてもらえれば動くと思います。
付属のモデルは再配布以外の使用ならご自由に使ってくださって構いません!(リアルタイム移動なので大分軽くしてます!)

https://drive.google.com/drive/folders/1mTecDCmLz8LSx1E-kRyIJ8x_LSKlbwHu?usp=sharing

参考文献

本記事と当NPC無人機を作る際に多大なる参考にさせて頂きました。
この場であらためて感謝を。VR世界でNPC戦闘機とドッグファイトするという自分の夢の1つを叶えてくださりありがとうございました!
・Cluster World Tools(cluster様  https://creator.cluster.mu/2023/06/19/clusterworldtools/)
・CSEmulator V2 (かおもラボ様  https://booth.pm/ja/items/5111235)
クリエイターズガイド
 cluster様(https://creator.cluster.mu/)
vins様 
 https://zenn.dev/vins/articles/649a70dfe2ab17

ねおりん様「cluster に飛行機を実装する【2022年最新版】」(https://zenn.dev/noir_neo/articles/09087a159d8479
ほびわん様のスクリプトノートhttps://scrapbox.io/hobione-note/ClusterScript%E4%BD%9C%E4%BE%8B

Yamara様 https://qiita.com/Yamara/items/d6e3924e91c0b8cc5019

わかゲームスタジオ様 https://wakagamestudio.hatenablog.jp/entry/2020/08/21/220601

あばっしゅ!まさぴー様 

かせー様 https://note.com/kasei_s/n/nf6a30f35a2b5

本当の最後

このNPC戦闘機をメインにしたゲームワールドを作ったので、
めっっっちゃ暇な時にでもお越しください!
図々しくもお願いいたします!
https://cluster.mu/w/45a72b2c-b6e3-4173-a50e-d094340a5ded

この記事が気に入ったらサポートをしてみませんか?