⚔️

【clusterベータ】敵とかからプレイヤーのパラメータに干渉する

2023/12/02に公開

clusterクリエイター・アドベントカレンダー2023(その1)、2日目です。

clusterにベータ機能が入ったことで、スクリプトでできる範囲が非常に広がりました!

特にアイテムとアイテム、アイテムとプレイヤーの間で情報をやりとりする方法がかなり増えたのは大きいですね。近くの敵にまとめて攻撃力と同じだけのダメージを与える、なんていうのは以前かなりやりづらかったことの1つでした。

clusterベータでまだできないこと

とはいえ、まだスムーズにできないこともあります。
プレイヤーのパラメータ(HPとか経験値とか)に干渉する、というのはその1つです。

$.getPlayersNearなどでPlayerHandleを取得しても、得られたり変えたりできるのはプレイヤーの位置・回転・速度などだけ。
プレイヤーのパラメータ(HP、攻撃力、経験値、ワールド入室回数etc……)については直接変更することはできないのです。

じゃあどうするか?

アイテムから、そのOwnerのパラメータを取得・変更することは可能です。ロジック(Logic)を使ってもいいですし、スクリプトからもいけます。

let hp = $.getStateCompat("owner","HP","integer");
hp -= 3;
$.setStateCompat("owner","HP",hp);

例えばこういう風にすれば、そのアイテムのOwnerのHPを3減らすことができます。

つまり、
敵とか→アイテム→そのアイテムのOwnerのプレイヤー
この2段階を踏めば、敵などがプレイヤーのHPを減らしたり、経験値をあげたりすることができます!

敵が直接ダメージを与えるのではなく、アイテムが中継してダメージを与える

プレイヤーごとに1つのアイテムがついてくる形

この形を成立させるにはどうすればいいか?
各プレイヤーに1つずつピッタリとついてくる透明アイテムがあればいいですね。

敵はプレイヤーではなく、その透明アイテムに向けて移動する。そして透明アイテムに対してダメージのメッセージを送り、透明アイテムがOwnerにダメージを伝える……こんな流れです。

プレイヤーにぴったりついてくるアイテム

まずプレイヤーの開始位置を、敵などがいる場所とは違う、離れた場所にします。
そしてボタンを押すことで敵などがいるエリアにワープする形とします。

このとき、ワープした瞬間、ワープ先に透明なアイテムを生成するのです。
そして透明なアイテムは、$.onUpdateの中で周囲にいるプレイヤーを探します。周囲にプレイヤーが見つかったら、その後はそのプレイヤーにぴったりついていく。
こうすることで、プレイヤー1人につき1つの透明なアイテムが割り当てられます。

サンプルプロジェクト

こちらからダウンロードして解凍し、「sample」フォルダをUnity Hubから読み込んでください。
「アセット」フォルダの中身は好きに活用してかまいません。クレジットとかも書かなくてOKです。

最初はCCKのダウンロードなどで少し時間がかかります。
開いたら、「アセット/シーン/プレイヤーの情報を得る」を開いてください。

また、スクリプトを使っているのでUnity上では動きません。(ベータ機能をONにして)clusterにアップロードし、cluster上で動作を確認してください。



ボタンを押してメインのエリアに行き、敵に近づくと敵が追ってきます。


敵が一定距離まで来ると、HPを減らされてしまいます。

実装のポイント部分

全体を説明しているとかなりの分量になってしまうので、ポイントをしぼって説明します。
(それでもかなり多いですが……)

ワープボタン

開始位置の近くにあるボタンです。

  • プレイヤーをposObjの位置にワープさせる
  • そこに透明なアイテムを生成
  • HPを初期化
    という3つの役割です。
const posObj = $.subNode("pos");
$.onInteract((player) => {
  // プレイヤーをワープさせる(posObjはこのアイテムの子なのでグローバル座標に変換する)
  player.setPosition(posObj.getPosition().sub($.getPosition()));

  //同じ位置にアイテムを生成する
  $.sendSignalCompat("this", "createParts");

  //HPを10にする
  $.setStateCompat("owner","HP", 10);
});

createPartsを自身に送ると、ワープボタンにつけてあるCreate Item Gimmickが反応します。

ワープする場所も生成される場所も「pos」のところなので、透明なアイテムはすぐにワープしてきたプレイヤーを見つけます。

透明なパーツ(基本部分)

プレイヤー1人につき1つ作られ、ずっとぴったりついてくる透明なアイテムです。
透明なアイテムと言っても、敵から発見してもらうのにも使わないといけないので、Sphere ColliderOverlap Source Shapeがついています。あと動くので、MovableItem + Rigidbodyも必要ですね。

$.onUpdate内のメイン部分はこんな感じです。

  //プレイヤーが見つかっていない場合はプレイヤーを探す
  if (!$.state.playerFound) {
    playerFindProc(deltaTime);
  } else {
    $.state.tick += deltaTime;

    // 0.1秒ごと(初期値)に位置を更新する
    if ($.state.tick < setPosPerSec) {
      // 0.1秒経過していないときは何もしない
      return;
    }
    $.state.tick -= setPosPerSec;

    //アイテムの位置をプレイヤーの位置に合わせる
    $.setPosition($.state.playerHandle.getPosition());

    //落下するか、プレイヤーがログアウトしていたら消える
    if ($.getPosition().y < -0.5 || !$.state.playerHandle.exists()) {
      $.destroy();
      return;
    }

まず生成された直後はプレイヤーが近くにいないか探します(別の関数)。

プレイヤーを見つけた後は、0.1秒(setPosPerSecで設定可能)ごとにプレイヤーの位置にワープさせます。もしプレイヤーが落下したりログアウトしていたりしていた場合は自分を破壊します。

  $.state.tick += deltaTime;
  // 1秒経過しても対象のプレイヤーが見つからなかったら消える
  if ($.state.tick > 1) {
    $.destroy();
    return;
  }

  // 0.4m以内にプレイヤーがいるか?
  let near = $.getPlayersNear($.getPosition(), 0.4);
  near.forEach((player) => {
    //最初に見つかったプレイヤーを対象にする
    $.state.playerFound = true;
    $.state.playerHandle = player;
    return;
  });

これはplayerFindProcのメイン部分です。
何らかの理由で、生成後1秒経ってもプレイヤーが見つからない場合は自身を破壊します。そのまま放っておくと、次に来たプレイヤーにくっついてしまうからですね。

あとは単純に近くにいるプレイヤーを探し、見つかったらそのプレイヤーのPlayerHandleを記録します。

敵(基本部分)

敵も動くのでMovableItem + Rigidbodyは必要です。
$.onUpdateでは「周りの透明アイテムの位置情報を求める」→「最も近い透明アイテムをターゲットとする」というループを行っています。

そして透明アイテムに向かって動き続け、透明アイテムに十分近いときは攻撃メッセージを送ります(その後は次の行動まで0.7秒停止)。

まず透明アイテムの位置情報を求めるためにメッセージを送る部分です。

  //getPosArを初期化
  $.state.getPosAr = [];
  //このアイテムの位置を取得
  let position = $.getPosition();

  //近くにある他のアイテムを取得
  let items = $.getItemsNear(position, maxDistance);
  // それぞれのアイテムに位置を送るように指示(敵とかはこのメッセージを無視する)
  items.forEach((item) => {
    item.send("getPos", null);
  });

近くにあるアイテムすべてに"getPos"というメッセージをsendしています。

このとき敵などにもメッセージが送られてしまいますが、透明アイテムのほうしか受けとらないように作られています(後述)。

透明アイテム(メッセージ受信)

//敵からのメッセージを受け取る
$.onReceive((messageType, arg, sender) => {
  switch (messageType) {
    case "getPos":
      //このアイテムの位置を送る
      sender.send("sendItem", $.getPosition());
      break;
//以下略

getPosというメッセージが送られてきた透明アイテムは、sender、つまり送り主の敵に自分の位置を"sendItem"というメッセージで送り返します。

このメッセージ送信→すぐ送り主に返信という流れは、clusterベータでこそできる面白い仕組みですね。

敵(メッセージ受信)

敵は送られてきた透明アイテムのItemHandleと位置をセットで記録します。

スクリプト内にもコメントで入れましたが、このとき大事なのは $.state.getPosAr.pushのように直接情報を追加しようとしてもダメ ということです。
$.stateの中にある配列の情報をいじりたいときは、まず let ar のように一時的にデータを格納する変数を作らないといけません。

$.onReceive((messageType, arg, sender) => {
  switch (messageType) {
    case "sendItem":
      //$.state.getPosArに、送られてきたプレイヤー(についているアイテム)の情報を記録
      //注意:直接$.state.getPosAr.push(sender)と書くと、反映されない
      let ar = $.state.getPosAr;
      ar.push([sender, arg]);
      $.state.getPosAr = ar;
      break;
  }
});

敵(最も近い透明アイテムをターゲットに)

さて、こうして送られてきた位置情報を使い、どの透明アイテム(プレイヤー)をターゲットにするか決めます。
$.state.getPosArに入っている情報を使い、敵からどれくらい離れているかを計算していきます。このときlengthではなくlengthSqを使い、距離の2乗を使うとスピードが速くなります。

なぜ2乗だと早いのか?

2つの位置がどれだけ離れているかの距離を計算するのは、中学で習った三平方の定理を使います。「Xの差」と「Yの差」をタテヨコの辺としたとき、「ナナメの辺」が長さになります。
詳しく知りたい人はこんな記事などを

で、三平方の定理でナナメの辺の長さを出すときは平方根を使います。
でもここで 「平方根わざわざ使わなくても、2乗された数字のままでいいや」とすると、計算が速くなる わけです。
「AとBのどちらが大きいか?」と「Aの2乗とBの2乗のどちらが大きいか?」の結果は同じですからね(数字が0以上のときは)。

で、最終的に「これが一番近い」という透明アイテムをターゲットとします。

  //1つもアイテムが見つからなかった場合は何もしない
  if ($.state.getPosAr.length == 0) {
    $.state.targetPlayerItem = null;
    return;
  }

  //初期化
  let targetDistance = Infinity;
  let targetPlayerItem = null;
  let targetPos = null;

  //このアイテムの位置を取得
  let position = $.getPosition();

  //$.state.getPosArには、getPosのメッセージに対応して
  //sendItemを送ってきたプレイヤー(についているアイテム)の情報が入っている

  // それぞれのプレイヤー(についているアイテム)との距離を計算し、最も近いものを探す
  $.state.getPosAr.forEach((item) => {
    //なお、距離はベクトルの長さの2乗で計算すると高速になる
    let distance = item[1].clone().sub(position).lengthSq();
    //より近いプレイヤーを対象にする
    if (distance < targetDistance) {
      targetDistance = distance;
      targetPlayerItem = item[0];
      targetPos = item[1];
    }
  });

  //見つかった(プレイヤーについている)アイテムの位置を記録
  $.state.targetPos = targetPos;
  $.state.targetPlayerItem = targetPlayerItem;

敵の移動と攻撃

敵の移動部分はほとんど公式記事の「近くのプレイヤーについてくるペットをつくる」と変わりません。
ただ、物理法則で移動させるとどうも動きが安定しなかったので、setPositionで移動させるように変えています。

そして距離が十分近いときは、記録しておいた対象の透明アイテムにsendでメッセージを送ります。
配列で情報を送ってもいいのですが、ここは「辞書配列」で送ってみました。

    $.state.targetPlayerItem.send("attack", {
      pos: position,
      power: attackPower,
    });

プレイヤーがダメージを受ける

まずonReceiveでメッセージを受けとる部分です。

敵が持っている位置情報は少し古いものですから、プレイヤーが敵から逃げてもっと遠くに行っているかもしれません。なので、プレイヤーの位置が本当に近いかをチェックしています。
また、サンプルプロジェクトでは「プレイヤーがジャンプしているとき(Y座標が大きいとき)は当たらない」という処理も入れています。

//敵からのメッセージを受け取る
$.onReceive((messageType, arg, sender) => {
  switch (messageType) {
  //中略
    case "attack":
      //相手の座標が近くにあるときのみダメージを与える
      if (isNearPlayerPos(arg.pos)) {
        dealDamage(arg.power);
      }
    break;
  }
});

最後にdealDamage関数の中身です。
この記事の最初のほうに書いたとおり、 getStateCompatとsetStateCompatを活用して透明アイテムのOwnerのパラメータを取得・変更しています。

また、ダメージを受けたとき音が出るように自身にsendSignalCompatをしています。
さらにHPが0になった場合プレイヤーを床の下に移動させて落下させ、「ゲームオーバー」のような感じにすることもしています。このときは自身を破壊していまいます(再度ゲームを開始するためボタンを押したとき、アイテムが2つにならないように)。

  //まず現在のHPを取得
  let hp = $.getStateCompat("owner", "HP","integer");
  //ダメージ分だけHPを減らす
  hp -= damage;
  //HPが0以下になったら
  if(hp <= 0) {
    //マイナスにならないように0にする
    hp = 0;
    //プレイヤーを床の下へ移動させる
    $.state.playerHandle.setPosition(new Vector3(0, -2, 0));
  }
  //プレイヤーのHPを更新
  $.setStateCompat("owner","HP", hp);

  //音を鳴らすためのシグナルを送る
  $.sendSignalCompat("this", "attacked");

  if(hp <= 0) {
    //プレイヤーのHPが0になったら消える
    $.destroy();
  }

最後に

いかがでしょうか、かなり「メンドくさい」という印象を持った方が多いでしょうね……
PlayerHandleからプレイヤーのパラメータ取得はもっとカンタンになってほしいというのが正直なところではあります。。。

しかしRPGっぽいワールドに限らず、NPCとの会話回数を記録したりといった手法にも使えるので、ある程度スクリプトが書ける人にはぜひ挑戦して頂きたいところです。

また透明アイテムではなく 「ボタンとしての見た目」を持たせ、プレイヤーから少しだけ離れた位置についてこさせる のも面白いですね! どんな場所にいてもコマンド入力するようなことが可能になります。

clusterベータでは、onUpdateの間隔を長くする代わりに「○○についてくる」をよりカンタンに実現するアップデートも予定されているようなので、今後に期待です。

Discussion