【ベータ機能・サンプル付き】Cluster HUD v2で銃の構え方の指定・いつでもリロードできるようにしてみよう

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

「PlayerScript」で扱うCluster HUD v2では、従来のHUDのようにGrabbable Itemをアバターの正面に持ち上げないようになりました。アバターに対してより表現力の高いポーズを設定できるようになった分、設定を行わないと銃のようなアイテムで前に向かって弾を撃つことができません。

そこで、この記事では、PlayerScriptの「プレイヤーのアバターの姿勢を指定する」機能を使って、従来HUDのように銃をアバターの正面に持ち上げ、前に撃つことができるアイテムについて解説します。本記事では、サンプルアイテムを配布します。

また、このサンプルアイテムでは、Cluster HUD v2の「ボタンを追加する機能」を使って、カメラワークを変更するボタンや、弾のリロードを行うボタンを追加し、従来のテンプレートの銃をアップデートしています。

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

一部ベータAPIを利用しているため、使用するためにはベータ機能や下記で解説する World Runtime Setting の設定が必要になります。

ベータ機能を有効にする

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

HUD v2を有効にする

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

アイテムの構成は下記のようになっています。

このGameObjectの内、「Gun」に下記のコンポーネントが割り当てられています。以下に各コンポーネントの役割・設定を説明します。

Gun
  • Item
  • Grabbable Item
    • アイテムに掴める機能を付与する
  • Scriptable Item
    • 掴んだ時に掴んだプレイヤーに対してPlayerScriptを登録させる役割
  • Player Script
    • ボタンを追加する機能
    • 射撃、エイム、リロードができる機能
    • プレイヤーのアバターの姿勢を更新する機能
  • Icon Asset List
    • HUD上に表示するアイコンを設定するためのリソース
      • Id: Shoot
        • Image には射撃ボタンとして使用するアイコンが割り当てられています。
      • Id: Reloading
        • Image にはリロード中に使用するアイコンが割り当てられています。
      • Id: Aim
        • Image にはエイムボタンとして使用するアイコンが割り当てられています。
      • Id: Reload
        • Image にはリロードボタンとして使用するアイコンが割り当てられています。
  • Create Item Gimmick
    • Scriptable ItemからのSignalを受けて弾のアイテムを生成する役割

Idはスクリプトから参照するため、名前は変更しないようにしてください。

スクリプト

// リロードにかかる時間
const reloadDuration = 1.5;

// 銃の最大弾数
const totalBulletCount = 6;

// 黄色い光線のGameObjectをSubNodeとして取得
const ray = $.subNode("Ray");

// 残弾表示用のGameObjectをSubNodeとして取得
const canvas = $.subNode("Canvas");

$.onStart(() => {
  // このカウントがreloadDurationより小さい値になっている場合、リロード処理中であることを表す
  $.state.reloadCount = reloadDuration;

  // 掴んでいるプレイヤーのPlayerHandleを格納するstate
  $.state.grabberPlayer = null;
});

$.onGrab((isGrab, isLeftHand, player) => {

  // アイテムを掴んだ時の処理
  if (isGrab) {

    // 全弾チャージ
    setBulletCount(totalBulletCount);

    // アイテムを掴んだプレイヤーにPlayerScriptを適用する
    $.setPlayerScript(player);

    // PlayerScriptに"CanShoot"というメッセージを送信
    player.send("CanShoot", true);

    // このアイテムを掴んでいるプレイヤーの情報を更新
    $.state.grabberPlayer = player;


  // アイテムを手放した時の処理
  } else {

    // 黄色い光線を非表示にする
    setRayActive(false);

    // PlayerScriptに"Released"というメッセージを送信
    player.send("Released", null);

    // このアイテムを掴んでいるプレイヤーの情報を削除
    $.state.grabberPlayer = null;

  }

  // アイテムを掴んだ時は残弾数を表示、手放した時は残弾数を非表示
  canvas.setEnabled(isGrab);

});

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

    // "Shoot"というメッセージを受信した時
    case "Shoot":
      // 射撃を行う
      shootBullet(sender);
      break;

    // "ShowRay"というメッセージを受信した時
    case "ShowRay":
      // argの真偽値に応じて黄色い光線の表示を切り替える
      setRayActive(arg);
      break;

    // "Reload"というメッセージを受信した時
    case "Reload":
      // リロード
      reloadBullet(sender);
      break;

    // どのcaseにも当てはまらない時
    default:
      break;

  }
},
// onReceiveにおいて、他のアイテムからのメッセージは受け取らず、PlayerScriptからのメッセージは受け取る設定
{ item: false , player: true });

// VR環境でも射撃ができるように、onUseに射撃を設定しておく
$.onUse((isDown, player) => {
  if (isDown) {
    shootBullet(player);
  }
});

$.onUpdate((deltaTime) => { 

  // stateを変数に格納
  let reloadCount = $.state.reloadCount;
  let grabberPlayer = $.state.grabberPlayer;

  // リロード状態の場合
  if (reloadCount < reloadDuration) { 
    reloadCount += deltaTime;

    // 一定時間してから弾数をリセットする
    if (reloadCount >= reloadDuration) { 

      // 全弾チャージ
      setBulletCount(totalBulletCount);

      // Play Audio Source Gimmickでリロード完了SEを再生するためのSignalを送信
      $.sendSignalCompat("this", "Reloaded");

      // Set Animator Value Gimmickでリロード用アニメーションを停止するためのSignalを送信
      $.setStateCompat("this", "Reloading", false);

      // アイテムを掴んでいるプレイヤーがいる場合は、PlayerScriptに"CanShoot"というメッセージを送信
      if (grabberPlayer != null) { 
        grabberPlayer.send("CanShoot", true);
      }
    }
  }

  // stateを更新
  $.state.reloadCount = reloadCount;

});

// 弾を撃つ処理
function shootBullet(player){

  // stateを変数に格納
  let bullet = $.state.bullet;

  // 弾が残っていなければ処理を終了
  if (bullet <= 0) return;

  // Create Item Gimmickで弾を生成するためのSignalを送信
  $.sendSignalCompat("this", "Shoot");

  // 残弾数を更新する
  bullet = bullet - 1;
  setBulletCount(bullet);

  // 残弾がなくなったらリロード開始
  if (bullet <= 0) {
    reloadBullet(player);
  }
}

// 残弾数を設定する処理
function setBulletCount(count) {
  // Set Fill Amount Gimmickで残弾数を表示するためのStateを更新
  $.setStateCompat("this", "Bullets", count);
  $.state.bullet = count;
}

// リロードを開始する処理
function reloadBullet(player) {

  $.state.reloadCount = 0.0;

  // PlayerScriptに"CanShoot"というメッセージを送信
  player.send("CanShoot", false);

  // Play Audio Source Gimmickでリロード開始SEを再生するためのSignalを送信
  $.sendSignalCompat("this", "Reload");

  // Set Animator Value Gimmickでリロード用アニメーションを開始するためのSignalを送信
  $.setStateCompat("this", "Reloading", true);
}

// 黄色い光線の表示を切り替える処理
function setRayActive(isActive) {
  // isActiveがtrueであれば光線を表示、falseであれば非表示にする
  ray.setEnabled(isActive); 
}

スクリプトの解説

$.onGrabの中での処理

$.onGrab (アイテムを掴んだり、手放したりした時に実行される)にて、以下のように記述しています。

$.onGrab((isGrab, isLeftHand, player) => {

  // アイテムを掴んだ時の処理
  if (isGrab) {
    
    // 全弾チャージ
    setBulletCount(totalBulletCount);

    // アイテムを掴んだプレイヤーにPlayerScriptを適用する
    $.setPlayerScript(player);
    
    // PlayerScriptに"CanShoot"というメッセージを送信
    player.send("CanShoot", true);

    // このアイテムを掴んでいるプレイヤーの情報を更新
    $.state.grabberPlayer = player;

この記述により、銃のアイテムを掴んだプレイヤーに対して、$.setPlayerScript を実行しています。
$.setPlayerScript を実行すると、このアイテムのPlayer Script コンポーネントに設定されたスクリプトを、特定のプレイヤーに登録することができます。

また、銃のアイテムを掴んだり手放したりしたプレイヤーに対して、send を使用してメッセージを送信しています。これにより、プレイヤーに対してPlayerScriptが有効となるかどうかを制御しています。

$.onReceiveの中での処理

$.onReceiveでは、PlayerScriptから受け取ったメッセージをもとに、射撃や光線の表示、リロードといった処理を開始しています。

射撃
光線の表示
リロード

このアイテムでは、弾の生成や残弾数の表示などを、スクリプトではなくギミックコンポーネントで行っています。そのため、sendSignalCompatやsetStateCompatを随所で使用しています。

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

    // "Shoot"というメッセージを受信した時
    case "Shoot":
      // 射撃を行う
      shootBullet(sender);
      break;

    // "ShowRay"というメッセージを受信した時
    case "ShowRay":
      // argの真偽値に応じて黄色い光線の表示を切り替える
      setRayActive(arg);
      break;

    // "Reload"というメッセージを受信した時
    case "Reload":
      // リロード
      reloadBullet(sender);
      break;

    // どのcaseにも当てはまらない時
    default:
      break;

  }
},
// onReceiveにおいて、他のアイテムからのメッセージは受け取らず、PlayerScriptからのメッセージは受け取る設定
{ item: false , player: true });

onReceiveの最後に、{ item: false , player: true }という記述があります。このスクリプトはitemHandle.sendからのメッセージを受け取ることを想定していないため、item: falseとすることで他のアイテムからのメッセージを受け取らないようにしています。また、player: trueにしないとプレイヤーからのメッセージを受け取れないことにも注意してください。

$.onUseの中での処理

$.onUseでは、onReceiveで”Shoot”というメッセージを受信した時と同じように、射撃を行う関数を呼び出しています。これは、プレイヤーがVRデバイスを使用している場合にPlayerScriptの_.onButtonが実行されず、射撃ができなくなってしまうことへの対策です。

// VR環境でも射撃ができるように、onUseに射撃を設定しておく
$.onUse((isDown, player) => {
  if (isDown) {
    shootBullet(player);
  }
});

$.onUpdateの中での処理

$.onUpdateには、リロード時に1.5秒間のタイマーが発動するため、その処理を書いています。

$.onUpdate((deltaTime) => { 

  // stateを変数に格納
  let reloadCount = $.state.reloadCount;
  let grabberPlayer = $.state.grabberPlayer;

  // リロード状態の場合
  if (reloadCount < reloadDuration) { 
    reloadCount += deltaTime;

    // 一定時間してから弾数をリセットする
    if (reloadCount >= reloadDuration) { 

      // 全弾チャージ
      setBulletCount(totalBulletCount);

      // Play Audio Source Gimmickでリロード完了SEを再生するためのSignalを送信
      $.sendSignalCompat("this", "Reloaded");

      // Set Animator Value Gimmickでリロード用アニメーションを停止するためのSignalを送信
      $.setStateCompat("this", "Reloading", false);

      // アイテムを掴んでいるプレイヤーがいる場合は、PlayerScriptに"CanShoot"というメッセージを送信
      if (grabberPlayer != null) { 
        grabberPlayer.send("CanShoot", true);
      }
    }
  }

  // stateを更新
  $.state.reloadCount = reloadCount;

});

銃弾の生成や残弾数の管理、リロード、黄色い光線を処理する関数

スクリプトの後半では、いくつかの処理を関数にまとめています。

// 弾を撃つ処理
function shootBullet(player){

  // stateを変数に格納
  let bullet = $.state.bullet;

  // 弾が残っていなければ処理を終了
  if (bullet <= 0) return;

  // Create Item Gimmickで弾を生成するためのSignalを送信
  $.sendSignalCompat("this", "Shoot");

  // 残弾数を更新する
  bullet = bullet - 1;
  setBulletCount(bullet);

  // 残弾がなくなったらリロード開始
  if (bullet <= 0) {
    reloadBullet(player);
  }
}

// 残弾数を設定する処理
function setBulletCount(count) {
  // Set Fill Amount Gimmickで残弾数を表示するためのStateを更新
  $.setStateCompat("this", "Bullets", count);
  $.state.bullet = count;
}

// リロードを開始する処理
function reloadBullet(player) {

  $.state.reloadCount = 0.0;

  // PlayerScriptに"CanShoot"というメッセージを送信
  player.send("CanShoot", false);

  // Play Audio Source Gimmickでリロード開始SEを再生するためのSignalを送信
  $.sendSignalCompat("this", "Reload");

  // Set Animator Value Gimmickでリロード用アニメーションを開始するためのSignalを送信
  $.setStateCompat("this", "Reloading", true);
}

// 黄色い光線の表示を切り替える処理
function setRayActive(isActive) {
  // isActiveがtrueであれば光線を表示、falseであれば非表示にする
  ray.setEnabled(isActive); 
}

スクリプト

// アイコンを取得
const shootIcon = _.iconAsset("Shoot");
const reloadingIcon = _.iconAsset("Reloading");
const aimIcon = _.iconAsset("Aim");
const reloadIcon = _.iconAsset("Reload");

// 銃を持った時に、右腕~右手に適用する回転量
const upperArmRot = new Quaternion().setFromEulerAngles(new Vector3(-35, 0, -80));
const lowerArmRot = new Quaternion().setFromEulerAngles(new Vector3(-90, -90, -10));
const handRot = new Quaternion().setFromEulerAngles(new Vector3(-90, -90, 0));

// アイテムを掴んでる間はtrueとなる値
let isGrabbing = true;

// エイムボタンを押してズームアップしている間はtrueとなる値
let isAimed = false;

// 銃が撃てる間はtrueとなる値
let canShoot = true;

// プレイヤーがVRデバイスを使用して入室しているときにtrueとなる値
let isVr = _.isVr;

// 銃のItemIdを格納する変数
let sourceItemId = _.sourceItemId;

// カメラワークを設定する
setCamerawork(isAimed);

// ボタンを表示する
_.showButton(1, aimIcon);
_.showButton(2, reloadIcon);

_.onReceive((messageType, arg, sender) => {
  switch(messageType){

    // "Released"というメッセージを受信した時
    case "Released":
      // 全てのカメラワークをリセット
      resetCamerawork();

      // ボタンを非表示にする
      hideButtons();
      
      isGrabbing = false;
      break;

    // "CanShoot"というメッセージを受信した時
    case "CanShoot":
      // メッセージとともに送られてくるargの真偽値に応じてアイコンを変化させる
      // argがtrueの時は射撃可能なのでshootIconを、argがfalseの時は射撃不可能なためreloadingIconをボタンに設定する
      let icon = arg ? shootIcon : reloadingIcon;
      _.showButton(0, icon);

      canShoot = arg;
      break;

    // どのcaseにも当てはまらない時
    default:
      break;

  }
});


// 射撃ボタン
_.onButton(0, (isDown) => {
  if (!isDown || !canShoot) return;

  // アイテムに"Shoot"というメッセージを送信する
  _.sendTo(sourceItemId, "Shoot", null);

});


// エイムボタン
_.onButton(1, (isDown) => {
  if (isDown) { 

    // エイム状態を更新し、それに応じたカメラワークを設定する
    isAimed = !isAimed;
    setCamerawork(isAimed);

    // アイテムに"ShowRay"というメッセージを送信し、エイム中かどうかを伝達する
    _.sendTo(sourceItemId, "ShowRay", isAimed);

  }
});


// リロードボタン
_.onButton(2, (isDown) => {
  if (isDown) { 

    // アイテムに"Reload"というメッセージを送信する
    _.sendTo(sourceItemId, "Reload", null);

  }
});

// 毎フレーム実行する処理
_.onFrame((deltaTime) => {
  // プレイヤーのアバターの姿勢を更新する
  setUpFk();
});

// プレイヤーのアバターの姿勢を更新する
function setUpFk() {
  // アイテムを掴んでいないか、VRデバイスを使用している時は、処理を終了する
  if (!isGrabbing || isVr) return;
  
  // 三人称カメラの回転値を取得する
  let cameraRot = _.cameraHandle.getRotation();

  if (cameraRot !== null) {
    // RightUpperArm, RightLowerArm, RightHandに、三人称カメラの回転を適用する
    // Scriptの最初に設定した回転値をmutiplyすることで、腕の回転が自然になるように調整する
    _.setHumanoidBoneRotationOnFrame(HumanoidBone.RightUpperArm, cameraRot.clone().multiply(upperArmRot));
    _.setHumanoidBoneRotationOnFrame(HumanoidBone.RightLowerArm, cameraRot.clone().multiply(lowerArmRot));
    _.setHumanoidBoneRotationOnFrame(HumanoidBone.RightHand, cameraRot.clone().multiply(handRot));
  }
}

// エイム中かどうかでカメラワークを変更する
function setCamerawork(isAimed) { 

  // 三人称視点のとき、アバターが正面方向に向くよう姿勢を固定する
  _.cameraHandle.setThirdPersonAvatarForwardLock(true);

  // エイム:ON
  if (isAimed) { 
    _.cameraHandle.setThirdPersonDistance(1.7); // アバターとの距離
    _.cameraHandle.setFieldOfView(30); // 視野角
    _.cameraHandle.setThirdPersonAvatarScreenPosition(new Vector2(0.3, 0.4)); // アバターの表示位置

  // エイム:OFF
  } else {
    _.cameraHandle.setThirdPersonDistance(2.0); // アバターとの距離
    _.cameraHandle.setFieldOfView(50); // 視野角
    _.cameraHandle.setThirdPersonAvatarScreenPosition(new Vector2(0.35, 0.4)); // アバターの表示位置
  }
}

// 全てのカメラワークをリセット
function resetCamerawork(){
  _.cameraHandle.setFieldOfView(null);
  _.cameraHandle.setThirdPersonDistance(null);
  _.cameraHandle.setThirdPersonAvatarForwardLock(null);
  _.cameraHandle.setThirdPersonAvatarScreenPosition(null);
}

// ボタンを非表示にする
function hideButtons(){
  _.hideButton(0);
  _.hideButton(1); 
  _.hideButton(2); 
}

スクリプトの解説

HUD上にボタンを設置する

PlayerScriptのメソッドは、 “$” ではなく “_” で呼び出します。

_.isVrを使用して、プレイヤーがVRデバイスを使用しているかどうかを特定しています。

VRデバイスを使用しているプレイヤーは、_.showButtonや_.onButtonを実行しても何も起こりません。ですが、後述の _.onFrameの処理は行われます。このスクリプトでは、isVrがtrueの時に、後述する _.onFrameの処理の一部をスキップします。

// プレイヤーがVRデバイスを使用して入室しているときにtrueとなる値
let isVr = _.isVr;

// 銃のItemIdを格納する変数
let sourceItemId = _.sourceItemId;

// カメラワークを設定する
setCamerawork(isAimed);

// ボタンを表示する
_.showButton(1, aimIcon);
_.showButton(2, reloadIcon);

手放した時のカメラワークのリセット、リロード時に撃てないようにする

_.onReceiveには、先述のScriptable Itemからのメッセージを受け取った際の処理を書いています。”Released”は銃を手放した時に送られてくるメッセージなので、カメラワークをリセットし、ボタンを非表示にしています。”CanShoot”はアイテムを掴んだりリロードが完了した時にtrueとなり、リロードを開始した時にfalseとなる値です。これに応じて、射撃用のアイコンが、射撃不可能であることを示すアイコンに切り替わります。

_.onReceive((messageType, arg, sender) => {
  switch(messageType){

    // "Released"というメッセージを受信した時
    case "Released":
      // 全てのカメラワークをリセット
      resetCamerawork();

      // ボタンを非表示にする
      hideButtons();
      
      isGrabbing = false;
      break;

    // "CanShoot"というメッセージを受信した時
    case "CanShoot":
      // メッセージとともに送られてくるargの真偽値に応じてアイコンを変化させる
      // argがtrueの時は射撃可能なのでshootIconを、argがfalseの時は射撃不可能なためreloadingIconをボタンに設定する
      let icon = arg ? shootIcon : reloadingIcon;
      _.showButton(0, icon);

      canShoot = arg;
      break;

    // どのcaseにも当てはまらない時
    default:
      break;

  }
});

カメラワークの設定

カメラワークの設定についても見てみましょう。

_.cameraHandle.setThirdPersonAvatarForwardLockでは、三人称視点のときのアバターの向きを指定することができます。trueにすることで、三人称視点でカメラを回転した時にアバターが正面方向を向くようになります。

_.cameraHandle.setThirdPersonAvatarForwardLock が false となっている時は、以下の動画のようになります(動画では、後述する右腕を前に出す処理は無効化しています)。アイテムを持っていない時と同じように、三人称カメラが回転しても、アバターの胴体は回転しません。

_.cameraHandle.setThirdPersonAvatarForwardLock を true にすることで、以下の動画のようになります。三人称カメラが回転すると、それに応じてアバターが回転します。

_.cameraHandle.setThirdPersonDistanceでは、アバターとカメラの距離を指定することができます。エイムがONの時は、アバターとカメラの距離を少し縮めています。

_.cameraHandle.setFieldOfViewでは、カメラの視野角を指定することができます。エイムがONの時は、視野角を狭めています。

_.cameraHandle.setThirdPersonDistance と _.cameraHandle.setFieldOfView を変更することで、以下の動画のようにカメラが切り替わります。

_.cameraHandle.setThirdPersonAvatarScreenPositionでは、アバターの頭部付近が画面上で映る位置を指定することができます。

// エイム中かどうかでカメラワークを変更する
function setCamerawork(isAimed) { 

  // 三人称視点のとき、アバターが正面方向に向くよう姿勢を固定する
  _.cameraHandle.setThirdPersonAvatarForwardLock(true);

  // エイム:ON
  if (isAimed) { 
    _.cameraHandle.setThirdPersonDistance(1.7); // アバターとの距離
    _.cameraHandle.setFieldOfView(30); // 視野角
    _.cameraHandle.setThirdPersonAvatarScreenPosition(new Vector2(0.3, 0.4)); // アバターの表示位置

  // エイム:OFF
  } else {
    _.cameraHandle.setThirdPersonDistance(2.0); // アバターとの距離
    _.cameraHandle.setFieldOfView(50); // 視野角
    _.cameraHandle.setThirdPersonAvatarScreenPosition(new Vector2(0.35, 0.4)); // アバターの表示位置
  }
}

これらの値は、アイテムの用途や好みに応じて調整してみてください。

ボタンを押した時の射撃、エイム、リロードの処理

射撃、エイム、リロードを行うには、PlayerScriptでボタンの押下を検知し、Scriptable Item宛にメッセージを送信する必要があります。

射撃
エイム
リロード

_.onButtonには、ボタンを押した時の動作が書かれています。デスクトップ環境においては .onButton(0, … ) が左クリック、.onButton(1, … ) が右クリック、.onButton(2, … ) がEキーに割り当てられています。それぞれ、_.sendToを使用してScriptable Item宛にメッセージを送信しています。

// 射撃ボタン
_.onButton(0, (isDown) => {
  if (!isDown || !canShoot) return;

  // アイテムに"Shoot"というメッセージを送信する
  _.sendTo(sourceItemId, "Shoot", null);

});

// エイムボタン
_.onButton(1, (isDown) => {
  if (isDown) { 

    // エイム状態を更新し、それに応じたカメラワークを設定する
    isAimed = !isAimed;
    setCamerawork(isAimed);

    // アイテムに"ShowRay"というメッセージを送信し、エイム中かどうかを伝達する
    _.sendTo(sourceItemId, "ShowRay", isAimed);

  }
});

// リロードボタン
_.onButton(2, (isDown) => {
  if (isDown) { 

    // アイテムに"Reload"というメッセージを送信する
    _.sendTo(sourceItemId, "Reload", null);

  }
});

プレイヤーのアバターの姿勢を更新する

_.onFrameでは、プレイヤーのアバターが銃を撃つのに適した姿勢になるように、右上腕、右前腕、右手の3つのボーンの回転を指定しています。_.cameraHandle.getRotationを使用して三人称カメラの回転値を取得し、その値を加工して、_.setHumanoidBoneRotationOnFrameを使ってボーンへの回転として適用しています。

// 毎フレーム実行する処理
_.onFrame((deltaTime) => {
  // プレイヤーのアバターの姿勢を更新する
  setUpFk();
});

// プレイヤーのアバターの姿勢を更新する
function setUpFk() {
  // アイテムを掴んでいないか、VRデバイスを使用している時は、処理を終了する
  if (!isGrabbing || isVr) return;
  
  // 三人称カメラの回転値を取得する
  let cameraRot = _.cameraHandle.getRotation();

  if (cameraRot !== null) {
    // RightUpperArm, RightLowerArm, RightHandに、三人称カメラの回転を適用する
    // Scriptの最初に設定した回転値をmutiplyすることで、腕の回転が自然になるように調整する
    _.setHumanoidBoneRotationOnFrame(HumanoidBone.RightUpperArm, cameraRot.clone().multiply(upperArmRot));
    _.setHumanoidBoneRotationOnFrame(HumanoidBone.RightLowerArm, cameraRot.clone().multiply(lowerArmRot));
    _.setHumanoidBoneRotationOnFrame(HumanoidBone.RightHand, cameraRot.clone().multiply(handRot));
  }
}

ここでの処理は、VRデバイスを使用している時は実行されないようにします。VRデバイスを使用しているときに_.setHumanoidBoneRotationOnFrameが実行されると、VRコントローラーを動かしても銃を動かせなくなってしまうからです。

// プレイヤーのアバターの姿勢を更新する
function setUpFk() {
  // アイテムを掴んでいないか、VRデバイスを使用している時は、処理を終了する
  if (!isGrabbing || isVr) return;

まず、_.cameraHandle.getRotationを使用して、プレイヤーの三人称カメラの回転値を、グローバル座標で取得します。

  // 三人称カメラの回転値を取得する
  let cameraRot = _.cameraHandle.getRotation();

続いて、右上腕(RightUpperArm)、右前腕(RightUpperArm)、右手(RightHand)の各ボーンに回転を適用していきます。これにより、プレイヤーが常に銃を突き出すような姿勢になります。

    // RightUpperArm, RightLowerArm, RightHandには、三人称カメラのX,Y,Z軸すべての回転を適用する
    // Scriptの最初に設定した回転値をmutiplyすることで、腕の回転が自然になるように調整する
    _.setHumanoidBoneRotationOnFrame(HumanoidBone.RightUpperArm, cameraRot.clone().multiply(upperArmRot));
    _.setHumanoidBoneRotationOnFrame(HumanoidBone.RightLowerArm, cameraRot.clone().multiply(lowerArmRot));
    _.setHumanoidBoneRotationOnFrame(HumanoidBone.RightHand, cameraRot.clone().multiply(handRot));
  }

このとき、multiplyを使用して、カメラの回転値に対してボーン毎に個別の値を乗算しています。乗算する値は、PlayerScriptの冒頭で設定しています。

// 銃を持った時に、右腕~右手に適用する回転量
const upperArmRot = new Quaternion().setFromEulerAngles(new Vector3(-35, 0, -80));
const lowerArmRot = new Quaternion().setFromEulerAngles(new Vector3(-90, -90, -10));
const handRot = new Quaternion().setFromEulerAngles(new Vector3(-90, -90, 0));

upperArmRotやlowerArmRotの値を変更することで、銃を持った時の脇の開き具合や、どれくらい前に腕を突き出すかを調整することができます。ただし、handRotの値はそのままにしておくのがよいでしょう。

このように、ボーンの回転を原点から末端にかけて順番に指定することで姿勢を決定する手法を、「フォワードキネマティクス」、略してFKといいます。回転を適用する順番を変更するとうまく動かない場合があるので注意しましょう。特に、銃の持ち手であるRightHandの回転は、最後に設定しましょう。

Cluster HUD v2では、従来のHUDとは違い、アイテムを掴んだ時に自動で腕を前に突き出さなくなりました。一方で、この記事のようにPlayerScriptを使用することで、アイテムを持った時のプレイヤーの姿勢を、クリエイターが自由に設定することができるようになっています。

この記事ではFKの作例を紹介しましたが、スクリプトに自信がある方は、FKではなく、手の位置を先に決めてから腕の角度を計算するIK(インバースキネマティクス)に挑戦してみてもよいかもしれません。もちろん、FKのまま、より高機能な銃をつくることも可能です。ぜひ試してみてください。

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

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

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

続きを読む