見出し画像

「空飛ぶほうき」 ClusterScript コード解説

メタバース "cluster" のワールドクラフトアイテム「空飛ぶほうき」のスクリプトを大公開!


はじめに

こんにちは。普段はメタバースプラットフォーム cluster で遊んでいることが多いやまちゃんです。

さて、cluster のワールド・アイテム制作環境 Cluster Creator Kit (CCK) の先日のアップデート v2.26.0.1 にて、乗り物操作をスクリプトで直接受け取ることが出来る $.onSteer コールバックが追加されました。

上記ページにはサンプルスクリプトもあるため、CCK を利用可能であれば比較的簡単に「乗り物」が作れますが、こちらは前後左右上下の 3 軸に平行移動しか出来ず、乗り物として実用的とは言い難いものになります。

そこで、左右平行移動を左右(ヨー)旋回に改め、上下移動と前後移動の速度を調整して乗り物として実用的にしたアイテムを「空飛ぶほうき」「空飛ぶデッキブラシ」として公開しました。
「空飛ぶデッキブラシ (茶)」は 2024 年 11 月時点では無料配布となっているので、ぜひ手に入れてね!(ダイレクトマーケティング)

この $.onSteer コールバック、ワールドクラフト (※1) で従来は作れなかった乗り物が作れるという画期的なものである一方、ちょっととっつきにくいのも事実です。
……というわけで、出血大サービスで「空飛ぶほうき」で使用している ClusterScript (※2) を公開し、解説してみます!
ワールドクラフトにもっと乗り物が増えると楽しいなというのが半分、コード内容に不備があったら指摘してほしいなというのが半分w

※1 cluster アプリ内で完結して、マインクラフトのような操作感でワールド制作が行える機能
※2 特定のトリガごとの動作をコールバック関数の形で記述する、 JavaScript ベースの cluster のスクリプト環境

スクリプト解説

備考

本コードは、前述の cluster 公式 note に記載されているサンプルコードを基にしています。
前述のページ内に記述がないのでサンプルコードのライセンス形態が不明ですが、少なくとも私が改変した箇所については煮るなり焼くなりしていただいて結構です。

コード全文

本コードを全てコピーして *.js として保存し、Movable Item・Ridable Item・Scriptable Item コンポーネントが付与されたオブジェクトにアタッチすることで「空飛ぶほうき」と同じ挙動をする乗り物が作れます。

const audio_get_on = $.audio("get_on");
const audio_get_off = $.audio("get_off");

const VER_SPEED = 3; // [m/s]
const HOR_SPEED = 6; // [m/s]
const ANG_SPEED = 90; // [deg/s]

const hor_rot = new Quaternion().setFromAxisAngle(new Vector3(0, 1, 0), 180);

$.onStart(() => {
    $.state.driver = null;
    $.state.steerInput = new Vector2(0, 0);
    $.state.steerAdditionalAxisInput = 0;
});

$.onRide((isGetOn, player) =>{
    $.state.driver = (isGetOn) ? player : null;

    if (isGetOn) {
        audio_get_on.play();
    }
    else {
        audio_get_off.play();
    }
});
  
$.onSteer((input, player) =>{
    $.state.steerInput = input;
});

$.onSteerAdditionalAxis((input, player) => {
    $.state.steerAdditionalAxisInput = input;
});

$.onUpdate((deltaTime) => {
    if (!$.state.driver) {
        return;
    }
    if (!$.state.driver.exists()) {
        $.state.driver = null;
        return;
    }

    // note: original script causes error during changing avatar (null returned)
    //const direction = $.state.driver.getRotation().createEulerAngles();
    // player sit position is horizontally rotated 180 degs from ridable item
    const direction = $.getRotation().multiply(hor_rot).createEulerAngles();
    const forwardRadian = direction.y * Math.PI / 180;
    const sideRadian = ((direction.y + 90) % 360) * Math.PI / 180;

    const vectorForwardInput = new Vector3(
        Math.sin(forwardRadian) * $.state.steerInput.y * deltaTime * HOR_SPEED,
        0,
        Math.cos(forwardRadian) * $.state.steerInput.y * deltaTime * HOR_SPEED
    );
    const vectorSideInput = new Vector3(
        Math.sin(sideRadian) * $.state.steerInput.x * deltaTime * HOR_SPEED,
        0,
        Math.cos(sideRadian) * $.state.steerInput.x * deltaTime * HOR_SPEED
    );
    const vectorVerticalInput = new Vector3(
        0,
        $.state.steerAdditionalAxisInput * deltaTime * VER_SPEED,
        0
    );

    const newPosition = $.getPosition()
        .add(vectorForwardInput)
        //.add(vectorSideInput)
        .add(vectorVerticalInput);

    $.setPosition(newPosition);

    let rotation_side = new Quaternion().setFromAxisAngle(
        new Vector3(0, 1, 0), ANG_SPEED * $.state.steerInput.x * deltaTime);
    const newRotation = $.getRotation().multiply(rotation_side);

    $.setRotation(newRotation);
});

各部の解説

const audio_get_on = $.audio("get_on");
const audio_get_off = $.audio("get_off");

「空飛ぶほうき」は乗った時・降りた時に効果音を鳴らしているため、その指定となります。オーディオは Item Audio Set List で予め指定しておく必要があります。
効果音を鳴らさないのであれば必要ありません。

const VER_SPEED = 3; // [m/s]
const HOR_SPEED = 6; // [m/s]
const ANG_SPEED = 90; // [deg/s]

乗り物の移動速度を定義する定数です。
それぞれ、
「垂直方向の移動速度 [m/s]」(※メートル毎秒)
「前後方向の移動速度 [m/s]」
「左右旋回の角速度 [deg/s]」(※度毎秒)
となります。

値を変えることで操作感が変わります。上記数値は移動操作をした時に爽快感があるように少し速めの設定となっています。

const hor_rot = new Quaternion().setFromAxisAngle(new Vector3(0, 1, 0), 180);

謎の決め打ち箇所その 1。
Ridable Item(乗り物)のルートオブジェクトと、Sit Position のオブジェクトの向きの差を表しています。
(0, 1, 0) なるベクトル、つまり y 軸を中心として 180 度回転する、という意味です。
(Unity 由来の角度は "度" 単位で扱われることに注意)

クラフトアイテムを設置する際、以下の図のように初期状態では +z の向きが手前に来る向きで表示されます。また、Ridable Item に Sit Position を指定しないと、乗った時にアバターが +z の向きに座ります。

椅子をデフォルトの向きで置いて、座ってみた例
座面 (+z 向き) が手前側に表示され、座るとアバターが +z 向きになる。これは自然

つまり、ワールドクラフトで乗り物アイテムを設置した時の設置者の体の向きと、着座位置の向きが反対向きになるのですが、ほうきの場合はこれがどうも気持ち悪くて(下図のようにアイテム設置時のアバターと同じ向きに座るようにしたくて)180 度旋回させているため、その補正に使うための定数です。

デッキブラシを置いたときに毛先が手前側に表示され、座ると棒の方を向く
Unity 上でのオブジェクトの向きは実は毛先が前 (+z)・棒が後ろ (-z) となっており、アバターが後ろ向きに座る形になるので、これを補正している
ルートオブジェクトの +z (図の中央下に表示されている青い矢印) の向きと、Sit Position の +z (中央の緑の直方体から延びる青い矢印) の向きが反対方向を向いている

Sit Position を明示的に指定しない、または Sit Position の向きをルートオブジェクトと揃える場合は、

const hor_rot = new Quaternion().setFromAxisAngle(new Vector3(0, 1, 0), 0);

と変更(最後の 180 を 0 に書き換え、回転しないように)すると辻褄が合いますので、お試しください。

$.onStart(() => {
    $.state.driver = null;
    $.state.steerInput = new Vector2(0, 0);
    $.state.steerAdditionalAxisInput = 0;
});

スクリプト開始時の初期化処理。

  • いま乗っている人の PlayerHandle を null(誰も乗っていない)

  • 移動入力 (x, y)・追加入力の値を 0(無入力)

としています。

$.onRide((isGetOn, player) =>{
    $.state.driver = (isGetOn) ? player : null;

アイテムへ乗った時・降りた時の処理。
最初の行で、誰かが乗った時にはその人の PlayerHandle を、降りた時には null を保存しています。
これは後ほど $.onUpdate で使用されます。

    if (isGetOn) {
        audio_get_on.play();
    }
    else {
        audio_get_off.play();
    }
});

$.onRide 後半の if-else ブロックは、乗った時・降りた時の効果音を鳴らしている箇所です。
効果音を鳴らさないのであれば必要ありません。

$.onSteer((input, player) =>{
    $.state.steerInput = input;
});

$.onSteerAdditionalAxis((input, player) => {
    $.state.steerAdditionalAxisInput = input;
});

移動入力・追加入力の値が更新されたら、それぞれ値を保存します。
これは後ほど $.onUpdate で使用されます。

$.onUpdate((deltaTime) => {
    if (!$.state.driver) {
        return;
    }
    if (!$.state.driver.exists()) {
        $.state.driver = null;
        return;
    }

$.onUpdate は、周期的に(毎秒数十回)呼び出される処理です。
主な処理はこの中に書かれています。

冒頭はアイテムに乗っている人の確認処理で、

  • 誰も乗っていなければ、何もせずに終了する

  • 誰かが乗っていることになっているが、そのユーザが(退出したなどの理由で)見つからなければ、誰も乗っていないことにして終了する

というものです。

    // note: original script causes error during changing avatar (null returned)
    //const direction = $.state.driver.getRotation().createEulerAngles();
    // player sit position is horizontally rotated 180 degs from ridable item
    const direction = $.getRotation().multiply(hor_rot).createEulerAngles();

私の試行錯誤の後が残ってしまっているブロックです (笑)。

最初の direction を算出するコードのコメントアウトですが、元々のサンプルスクリプトでは「乗っている人 ($.state.driver) のワールド座標での向きを求めて (.getRotation())、そのオイラー角を求める (.createEulerAngle())」という処理になっています。
ところが、PlayerHandle.getRotation() はアバター変更中などに null を返却するため、このコードのままだと null のオイラー角を求めようとしてしまいエラーとなります。

そこで、「このアイテム=乗り物 ($) のワールド座標での向きを求めて (.getRotation())、乗り物全体と Sit Position の向きの違いを反映して (.multiply(hor_rot))、そのオイラー角を求める (.createEulerAngle())」という記述に置き換えています。
$.getRotation() は常に有効な値が返るので、エラー対策としての変更です。
謎の Quaternion.multiply() が出てきましたが、「クォータニオンの乗算は回転を反映することである」と丸暗記してください……(CCK や Unity 関係なく、クォータニオン自体の性質です)。

    const forwardRadian = direction.y * Math.PI / 180;
    const sideRadian = ((direction.y + 90) % 360) * Math.PI / 180;

乗っている人の向きの、+z の向きを 0 とした左右回転成分(ヨー成分)をラジアン単位で求めています。
forwardRadian は乗っている人の前方向の角度ですが、sideRadian は乗っている人の右方向の角度ですね。
これらは移動入力の前後・左右の正方向に対応しています。

    const vectorForwardInput = new Vector3(
        Math.sin(forwardRadian) * $.state.steerInput.y * deltaTime * HOR_SPEED,
        0,
        Math.cos(forwardRadian) * $.state.steerInput.y * deltaTime * HOR_SPEED
    );
    const vectorSideInput = new Vector3(
        Math.sin(sideRadian) * $.state.steerInput.x * deltaTime * HOR_SPEED,
        0,
        Math.cos(sideRadian) * $.state.steerInput.x * deltaTime * HOR_SPEED
    );

多分ここが最難関。
移動入力の量と乗っている人の向きを考慮して、乗り物の移動量を計算しています。

$.state.steerInput.x は移動入力の左右(右がプラス)、$.state.steerInput.y は上下(上がプラス)を表します。移動入力の上は前進、下は後退として扱われるので、前がプラスとなります。
次に、deltaTime * HOR_SPEED は「このフレームでの移動距離の絶対値」を表します。deltaTime の単位は [s]、HOR_SPEED は [m/s] なので、deltaTime * HOR_SPEED の単位は [m] です。

そして Math.sin, Math.cos ですが……これは移動入力に基づいて、乗っている人の向きの前後・左右の移動量のワールド座標系への割り振り方を決めています。
多分以下の画像のような考え方です。Unity が左手系であることに注意してください。

アバターの向きに応じた移動量の、ワールド座標系への反映

(2024-11-14 訂正)解説文・図中の移動入力の x, y の説明が逆になっていました。スクリプトの内容が正しいです。お詫びして訂正いたします。

ちなみに「空飛ぶほうき」では実は vectorSideInput は計算しているけど使用していません。移動入力の左右は平行移動ではなく旋回に使っていますので……。

    const vectorVerticalInput = new Vector3(
        0,
        $.state.steerAdditionalAxisInput * deltaTime * VER_SPEED,
        0
    );

追加入力の量から、乗り物の上下の移動量を計算しています。
$.state.steerAdditionalAxisInput が追加入力の上下(上がプラス)です。あとは HOR_SPEEDVER_SPEED に変わっただけなので、先ほどの難関を乗り切った方には簡単ですね!

    const newPosition = $.getPosition()
        .add(vectorForwardInput)
        //.add(vectorSideInput)
        .add(vectorVerticalInput);

    $.setPosition(newPosition);

乗り物に前後・上下の移動を反映します。
次の乗り物の位置として、現在の乗り物の位置を取得 ($.getPosition()) し、前後・上下の移動量を加算 (.add(vectorForwardInput).add(vectorVerticalInput)) しています。
左右の移動量は計算してあるけど足していません(コメントアウトしています)。
最後に、次の乗り物の位置を反映 ($.setPosition(newPosition)) します。

    let rotation_side = new Quaternion().setFromAxisAngle(
        new Vector3(0, 1, 0), ANG_SPEED * $.state.steerInput.x * deltaTime);
    const newRotation = $.getRotation().multiply(rotation_side);

    $.setRotation(newRotation);
});

乗り物に左右旋回を反映します。
まず左右旋回の回転量を求めます。回転量は、回転軸および回転角度を指定する形で定義します(new Quaternion().setFromAxisAngle())。
回転軸は y 軸 (new Vector3(0, 1, 0))、回転角度は ANG_SPEED * $.state.steerInput.x * deltaTime [deg] です。
deltaTime の単位が [s]、ANG_SPEED の単位が [deg/s] であること、Unity が左手系であることに注意してください。

そして、次の乗り物の角度として、現在の乗り物の角度を取得 ($.getRotation()) し、左右旋回の回転量を適用 (.multiply(rotation_side)) しています。
最後に、次の乗り物の角度を反映 ($.setRotation(newRotation)) します。

お疲れさまでした。以上でスクリプトの解説は終了です。

最後に

「cluster のワールドクラフトで乗り物が作れる!」という先進性とは裏腹に、クォータニオンや Unity の左手系問題など難易度高めな要素が多くて中々改造しづらいサンプルコードだなぁと思ったので、自分のアイテムの実際のスクリプトの中身をお見せしつつ解説してみました。
本記述が皆様の cluster クリエイターライフの一助となれば幸いです。

あ、そうそう。
本記事の作者は大学時代に線形代数や解析学がチョー苦手で再々試験まで行った人なので、記述の正確性は各自で検証してね!

いいなと思ったら応援しよう!