【ベータ機能・サンプル付き】PlayerScriptで「ボタンで出せるアバターモーション」をつくる

本記事はCluster Creator Kitの「ベータ機能」を利用した解説になります。
ベータ機能では正式リリース前の機能を使うことができます。正式リリース前のため、不安定な挙動をしたり将来的に挙動が変わる可能性があります。また、記事公開時点ではベータ機能として紹介していますが、その後正式リリースされている場合もありますので、本記事を参考にする場合はまずScript Referenceを参照することをおすすめします。

Cluster Creator Kitで新しく使えるようになった「PlayerScript」を使うと、各プレイヤーのポーズ変更をしたり、追加のボタン入力を利用したりといったことが可能になります。

アイテムに設定された「ClusterScript」と組み合わせることで今までにないさまざまなギミックをつくることができます!

今回はその作例として、ボタンを押すことでポーズと効果音を再生するエモート風の機能をつくってみます。記事の後半では、サンプルを発展させるためのヒントをサンプルコードと共にいくつか解説します!

今回作成するギミックはベータ機能を有効にしたUnity製のワールドで利用可能です。正式版やワールドクラフトでは使えません。

Table of Contents

この記事では、すぐに使えるサンプルアイテムを配布しています。
まずは使ってみたいという方は下記からダウンロードしてください。本記事では、サンプルアイテムの構成やコードについて解説していきます。

使用するためにはベータ機能や下記で解説する World Runtime Setting の設定などが必要になります。

Cluster Creator Kitをv2.18.0以上にアップデートする

Cluster Creator Kiのアップデート方法はこちらの記事をご覧ください。

ベータ機能を有効にする

利用にはCluster Creator Kitでベータ機能を設定した状態でアップロードを行う必要があります。設定方法はこちらをご覧ください。

HUD v2を有効にする

HUDとは、画面上に表示されるボタンなどの要素を指します。
今回のギミックを使うには、新しく追加された「HUD v2」機能を有効にする必要があります(詳しくはこちら)。
シーン上に設置したWorld Runtime Settingコンポーネントで、「Use Cluster HUD v2」を有効にしてください。

プレイヤーに追従するアイテムをクラフトアイテムとしてアップロードする

本サンプルプロジェクトを使用するためには、「FollowingItem.prefab」をベータ機能が有効なクラフトアイテムとしてアップロードし、「AttachFollowingItem.js」のItemTemplateIdの引数を書き換えてください。
ベータ機能が有効なクラフトアイテムのアップロードの仕方はこちらを参考にしてください。
ItemTemplateIdは上部メニューの「Cluster > クラフトアイテムの情報取得」から確認できます。

Unity製ワールドにおいて、$.createItem() での参照のために必要な手順です。本サンプルは、現時点ではワールドクラフトに設置しても正しく動作しません。

追従アイテムを生成するアイテムをワールドに設置してアップロードする

PlayerScriptAttacher.prefabをワールドに設置して、ベータ機能を有効にしてアップロードしてください。

ワールドに入室するとプレイヤーにアイテムが追従するようになり、従来のHUDでGrabbable Itemを手で持った時のようなカーソルロック状態になります。
PCでは「R」キー、モバイルでは画面上に表示されたボタンをタップすることで、モーションと効果音が再生されます。

今回のギミックでは、

  • プレイヤーに追従して効果音を鳴らすアイテムのスクリプト
  • PlayerScript
  • そのアイテムを生成してプレイヤーと紐づけるアイテムのスクリプト

の3つのスクリプトを利用します。

プレイヤーに追従するアイテム

プレイヤーひとりに対してひとつずつ生成され、常にそのプレイヤーの位置に追従するアイテムです。
紐づけられたプレイヤーからのメッセージを受けて効果音を鳴らします。

サンプル内の「FollowingItem.prefab」には、Scriptable Itemコンポーネントに以下のスクリプトが設定されています。

スクリプト内のunderLimitHeightの値はワールドのDespawn Heightや地面の高さに合わせて調整してください。

// アイテム位置の下限
// DespawnHeightより高くしておく
const underLimitHeight = -1.5;

const sound = $.audio("Sound");

const registerPlayer = (player) => {
    $.state.followingPlayer = player;
    $.setPlayerScript(player);
};

const playSound = () => {
    sound.play();
};

$.onStart(() => {
    $.state.followingPlayer = null;
});

$.onReceive((messageType, arg, sender) => {
    // 追従対象プレイヤーを登録
    if (messageType === "RegisterPlayer") {
        registerPlayer(arg);
    }

    // 効果音を鳴らす
    if (messageType === "PlaySound") {
        playSound();
    }
}, {item: true, player:true});

$.onUpdate((deltaTime) => {
    let followingPlayer = $.state.followingPlayer;
    if (!followingPlayer) return;

    // プレイヤーが居なくなるとアイテムも消える
    if (followingPlayer.exists() === false)
    {
        $.destroy();
        return;
    };

    // プレイヤー位置・回転に追従
    let position = followingPlayer.getPosition();
    // 位置の下限を適用
    position.y = Math.max(position.y, underLimitHeight);
    let rotation = followingPlayer.getRotation();
    $.setPosition(position);
    $.setRotation(rotation);
});

PlayerScript

PlayerScriptは各プレイヤーに対して動作する新しいスクリプトです。上のアイテムを通して、プレイヤーにPlayerScriptを適用します。
Player Scriptコンポーネントに設定した以下のスクリプトがプレイヤーに適用されます。

let targetItem = null;
let motionTime = 0;
let isPlayingMotion = false;

const animation1 = _.humanoidAnimation("Animation1");

// 値がある範囲に収まるようにする
const clamp = (x, min, max) => {
    if (x < min) {
        return min;
    }
    else if (max < x) {
        return max;
    }
    else {
        return x;
    }
};

// xが0→1になるとき、0→1→0となる値を返す
// 最初からeaseInRateの期間で0→1、最後からeaseOutRateの期間で1→0になる
const easeInOut = (x, easeInRate, easeOutRate) => {
    x = clamp(x, 0, 1);

    let y = 1;

    if (x < easeInRate) {
        y = x / easeInRate;
    }
    else if (1 - easeOutRate < x) {
        y = (1 - x) / easeOutRate;
    }

    return y;
};

// モーションを適用し、モーションが終わりならfalseを返す
const playMotion = (animation, time) => {
    let animationLength = animation.getLength();
    let continuePlaying = (motionTime <= animationLength);

    if (continuePlaying) {
        let pose = animation.getSample(time);

        // 通常モーションとスムーズに切り替わるよう、モーションの始めと終わりでweightが小さくなるようにする
        let timeRate = time / animationLength;
        let weight = easeInOut(timeRate, 0.1, 0.1);

        _.setHumanoidPoseOnFrame(pose, weight);

        // モーション再生は終わっていない
        return true;
    }

    return continuePlaying;
};

_.onStart(() => {
    _.showButton(3, _.iconAsset(""));
    targetItem = _.sourceItemId;
});

_.onButton(3, (isDown) => {
    if (isPlayingMotion) return;
    if (isDown) {
        // メッセージを送ってアイテムから音を鳴らす
        _.sendTo(targetItem, "PlaySound", null);

        // モーション再生開始
        isPlayingMotion = true;
        motionTime = 0;
    }
});

_.onFrame(deltaTime => {
    // モーション再生
    if (isPlayingMotion) {
        motionTime += deltaTime;
        isPlayingMotion = playMotion(animation1, motionTime);
    }
});

後述の$.createItem()での参照のため「FollowingItem.prefab」をベータ機能が有効なクラフトアイテムとしてアップロードする必要があります。

※Unity製ワールドで使用するために必要な手順であり、現時点では、本サンプルはワールドクラフトに設置しても正しく動作しません。

常にプレイヤーについてくるので、サンプルでは移動の邪魔にならないようコライダーにItem Select Shapeコンポーネントを設定して、物理衝突のないコライダーにしてあります。

追従アイテムを生成するアイテム

続いて、ワールドに入室したプレイヤーひとりひとりに対応して上記のアイテムを生成する機能について解説します。
サンプルの「AttachFollowingItem.prefab」のScriptable Itemコンポーネントには以下のスクリプトが設定されています。

ItemTemplateIdの引数は先ほどアップロードしたクラフトアイテムのItem Template IDに書き換えてください。アップロードしたアイテムのItem Template IDはUnityの上部メニューから「Cluster→クラフトアイテムの情報取得」で確認することができます。

// 各プレイヤーに追従するアイテムのID
const followingItemId = new ItemTemplateId("アップロードしたアイテムのItem Template ID");

$.onStart(() => {
    $.state.attachedPlayers = [];
});

$.onUpdate((deltaTime) => {
    // 適用済みプレイヤーの一覧
    // 居なくなったプレイヤーをfilterで除外
    let attachedPlayers = $.state.attachedPlayers.filter(player => player.exists());

    // ワールド内の全プレイヤーのうち、適用済み一覧に入っていないプレイヤーを取得
    let players = $.getPlayersNear($.getPosition(), Infinity);
    let playersToAttach = players.filter(player => !attachedPlayers.some(attachedPlayer => attachedPlayer.id === player.id));

    playersToAttach.forEach(player => {
        // 各プレイヤーに追従するアイテムを生成
        let followingItem = $.createItem(followingItemId, player.getPosition(), player.getRotation());

        // 生成したアイテムに追従対象プレイヤーを登録
        followingItem.send("RegisterPlayer", player);

        // 適用済みプレイヤーに追加
        attachedPlayers.push(player);
    });

    $.state.attachedPlayers = attachedPlayers;
});

追従するアイテム

PlayerScriptをセットする

const registerPlayer = (player) => {
    $.state.followingPlayer = player;
    $.setPlayerScript(player);
};

追従対象プレイヤーを登録し、同時にそのプレイヤーにPlayerScriptをセットします。
セットされるPlayerScriptは同じアイテムのPlayer Scriptコンポーネントに設定したものです。

他のアイテムやプレイヤーからsendされたときに呼び出される処理

$.onReceive((messageType, arg, sender) => {
    // 追従対象プレイヤーを登録
    if (messageType === "RegisterPlayer") {
        registerPlayer(arg);
    }

    // 効果音を鳴らす
    if (messageType === "PlaySound") {
        playSound();
    }
}, {item: true, player:true});

他のアイテムやプレイヤーからsendされたときに呼び出される処理です。

使い方はこれまでのonReceiveと変わらないですが、新たにオプションが追加され、アイテムからのメッセージとプレイヤーからのメッセージをそれぞれ受け取るかどうかを設定できるようになりました。
初期値のままではプレイヤーからのメッセージを受け取れないので、{item: true, player:true}としてアイテム・プレイヤーの両方から受け取れるようにしておきましょう。

プレイヤーの位置と回転を取得し、アイテムの位置に適用する

// プレイヤー位置・回転に追従
let position = followingPlayer.getPosition();
// 位置の下限を適用
position.y = Math.max(position.y, underLimitHeight);
let rotation = followingPlayer.getRotation();
$.setPosition(position);
$.setRotation(rotation);

プレイヤーの位置と回転を取得し、アイテムの位置に適用します。

プレイヤーがDespawn Heightより下に落ちてしまうと、アイテムもDespawn Heightより下に移動して消えてしまうので、ある高さより下には行かないように工夫しています。

PlayerScript

グローバル変数を定義する

let targetItem = null;
let motionTime = 0;
let isPlayingMotion = false;

PlayerScriptにはstateがありませんが、代わりにグローバル変数を利用することができます。

PlayerScript開始時の処理を設定する

_.onStart(() => {
    _.showButton(3, _.iconAsset(""));
    targetItem = _.sourceItemId;
});

PlayerScriptの各機能には「_」を通じてアクセスすることができます(アイテムの『$』と同様)。

_.onStart()はアイテムの$.onStart()と同様に、PlayerScriptが開始されたときに一度だけ呼ばれる処理です。

_.showButton(3, _.iconAsset(""));でHUD v2で使える追加ボタンを表示します。

今回は使っていませんが、PlayerScriptコンポーネントと同じアイテムにIcon Asset Listコンポーネントを設定し、画像と紐づけたIDを引数に渡すことでボタンのアイコンを変更することができます。また、_.sourceItemIdを通じてPlayerScriptをセットしたアイテムにアクセスすることができます。

ボタンが押されたときの処理を設定する

_.onButton(3, (isDown) => {
    if (isPlayingMotion) return;
    if (isDown) {
        // メッセージを送ってアイテムから音を鳴らす
        _.sendTo(targetItem, "PlaySound", null);

        // モーション再生開始
        isPlayingMotion = true;
        motionTime = 0;
    }
});

_.showButton()で表示したボタンが押されたときの処理は_.onButton()に記述します。
ここではモーション再生の開始と、アイテムから音を鳴らすためのメッセージ送信をしています。

モーションを再生する

_.onFrame(deltaTime => {
    // モーション再生
    if (isPlayingMotion) {
        motionTime += deltaTime;
        isPlayingMotion = playMotion(animation1, motionTime);
    }
});

_.onFrame()には毎フレーム呼び出される処理を記述します(アイテムの$.onUpdate()と似たもの)。
今回はモーション再生の処理を呼び出しています。

const playMotion = (animation, time) => {
    let animationLength = animation.getLength();
    let continuePlaying = (motionTime <= animationLength);

    if (continuePlaying) {
        let pose = animation.getSample(time);

        // 通常モーションとスムーズに切り替わるよう、モーションの始めと終わりでweightが小さくなるようにする
        let timeRate = time / animationLength;
        let weight = easeInOut(timeRate, 0.1, 0.1);

        _.setHumanoidPoseOnFrame(pose, weight);

        // モーション再生は終わっていない
        return true;
    }

    return continuePlaying;
};

_.onFrame()から呼び出しているモーション適用処理の実体がこの部分です。

アイテムのPlayerHandleに対するポーズ適用と似ていますが、頻度制限なくモーションを取らせたり、通常のモーションとブレンドしたりといったこともできます。またPlayerScriptでのポーズ適用はそのフレームでのみ有効で、ポーズ適用を呼び出さない場合は最後に適用したポーズは維持されず、通常のモーションに戻ります。

追従アイテムを生成するアイテム

処理対象になったプレイヤーの一覧の追加・参照

let attachedPlayers = $.state.attachedPlayers.filter(player => player.exists());

一度処理対象になったプレイヤーの一覧をstateに保存しておき、それ以降は除外するようにします。
onUpdate()の最初のこの部分では保存した一覧を読み込み、さらにそのうち現在ワールド内に存在している(退出していない)プレイヤーのみをフィルタリングしています。

    let players = $.getPlayersNear($.getPosition(), Infinity);
    let playersToAttach = players.filter(player => !attachedPlayers.some(attachedPlayer => attachedPlayer.id === player.id));

会場内の全プレイヤーを取得し、そのうち上記の一覧に含まれないプレイヤーを抽出して、まだ処理対象になっていない=これからアイテム生成と紐づけの処理をおこなう必要があるプレイヤーの一覧を生成します。

各プレイヤーに追従するアイテムを生成・登録し、適用済みのプレイヤーを一覧に追加する

playersToAttach.forEach(player => {
    // 各プレイヤーに追従するアイテムを生成
    let followingItem = $.createItem(followingItemId, player.getPosition(), player.getRotation());

    // 生成したアイテムに追従対象プレイヤーを登録
    followingItem.send("RegisterPlayer", player);

    // 適用済みプレイヤーに追加
    attachedPlayers.push(player);
});

生成した一覧に含まれる各プレイヤーごとに追従アイテムを生成し、そのアイテムに追従対象となるプレイヤーの情報をsendします。
その後、適用済みプレイヤーの一覧にプレイヤーを追加します。

このスクリプトを設定したアイテム(サンプルのPlayerScriptAttacher.prefab)をワールドに設置して、ベータ機能を有効にしてアップロードしてください。

ワールドに入室するとプレイヤーにアイテムが追従するようになり、従来のHUDでGrabbable Itemを手で持った時のようなカーソルロック状態になります。
PCでは「R」キー、モバイルでは画面上に表示されたボタンをタップすることで、モーションと効果音が再生されます。

PlayerScriptでは、この記事でまだ紹介できていない色々な機能を実現することができます。
最後に、本サンプルに付加できる機能を紹介します。

VRのプレイヤーにのみ追従するボタンが出現するようにする

PlayerScriptの_.showButton()で表示されるボタンはVRに対応していません
必要な場合は、代わりにプレイヤーがVR環境かどうかを取得できる_.isVrを使ってVR向けの機能を実装しましょう。

今回はプレイヤーがVRの場合のみ、追従アイテムにボタンを出現させます。
PlayerScriptの_.onStart()の部分を以下のように書き換えます。

_.onStart(() => {
    _.showButton(3, _.iconAsset(""));
    targetItem = _.sourceItemId;

    // プレイヤーがVRかどうかをアイテムに送信
    let isVr = _.isVr;
    _.sendTo(targetItem, "SetIsVr", isVr);
});

プレイヤーがVRで入室しているかどうかの情報をアイテムにsendしています。

このsendを受け取るよう、追従するアイテムのスクリプトの$.onReceive()の部分も以下のように書き換えましょう。

// 追加
const vrButton = $.subNode("VRButton");

// 追加
const setVrMode = (isVr) => {
    // VR用ボタンを有効化
    vrButton.setEnabled(isVr);
}

$.onReceive((messageType, arg, sender) => {
    // 略

    // 追加
    if (messageType === "SetIsVr") {
        setVrMode(arg);
    }
}, {item: true, player:true});

プレイヤーがVRのときのみ、「VRButton」という名前のsubNodeをボタンとして表示します。
アイテムの子オブジェクトとしてモデルとコライダーを含む「VRButton」を追加し、位置などを調整してください。コライダーにはInteractable Shapeコンポーネントも設定しておきます。

このボタンがクリックされたときの挙動もスクリプトに追加しておきましょう。

$.onInteract(player => {
    playSound();
});

以上の改変ができたら、追従アイテムを再度クラフトアイテムとしてアップロードします。
別のIDとしてアップロードされるので、生成する側のアイテムのスクリプトのitemTemplateIdの部分も差し替えるのを忘れないようにしてください。

以上の内容でワールドを更新して再入室すると、VRのプレイヤーにのみ追従するボタンが出現するようになります。
今回はVR専用ボタンの表示という形で対応しましたが、他にもVRならではの「手の位置」などといった方法を入力手段として使うこともできます。ワールドに合わせて工夫してみてください。

ボタンを増やしてカメラワークを切り替えられるようにする

_.showButton()で表示するボタンはindex=0~3の最大4つまで利用できます。
ボタンを追加して、別の機能を使えるようにしてみましょう。

ボタンを追加する

PlayerScriptの_.onStart()に、以下のように追加します。

_.onStart(() => {
    // 追加
    _.showButton(2, _.iconAsset(""));

    _.showButton(3, _.iconAsset(""));
    // 略
});

カメラワークを選択できるようにする

さらに、いま追加したボタンの機能を定義します。
試しに、同じくPlayerScriptの新しい機能として追加されたカメラ操作APIを使ってみましょう。
このAPIでは、アバターを映すカメラの位置や回転を取得し制御することができます。今回はボタンを押すたびに3人称カメラの位置が切り替わるようにしてみます。

let cameraMode = 0;

_.onButton(2, (isDown) => {
    if (isDown) {
        cameraMode++;

        switch(cameraMode) {
            case 1:
                _.cameraHandle.setThirdPersonDistance(0.5);
                break;
            case 2:
                _.cameraHandle.setThirdPersonAvatarScreenPosition(new Vector2(0.3, 0.3));
                break;
            default:
                cameraMode = 0;
                _.cameraHandle.setThirdPersonDistance(null);
                _.cameraHandle.setThirdPersonAvatarScreenPosition(null);
                break;
        }
    }
});

同様に、追従アイテムの再アップロードと生成用アイテムのスクリプト内のIDの書き換えをおこなってからワールドをアップロードしてみましょう。
再入室して新しく追加されたボタンを押すたびに、3人称視点のカメラ位置が切り替わるようになります。

HUD v2とPlayer Scriptを利用することで、これまでより幅広い入出力や、プレイヤーに関する操作などを使った今までにないギミックをつくることができます。
少し複雑かもしれませんが、ぜひ挑戦してみてください!

記事をシェアしてワールド制作を盛り上げよう!

Cluster Creators Guide|バーチャル空間での創作を学ぶならをもっと見る

今すぐ購読し、続きを読んで、すべてのアーカイブにアクセスしましょう。

続きを読む