NPCが動きまわるワールド『NPC Cat toy – ねこじゃらし遊び』に、NPCとボール遊びができる機能を追加しました。今までのものはNPCがねこじゃらしを追いかけたり、じゃれたりするもので、これをちょっと改変すれば簡単に作れそうだな~、と思ってたらすごい大変でした。前回の記事に続き、追加した分について書いていきたいと思います。

ワールド名 NPC Cat toy – ねこじゃらし遊び
URL https://vrchat.com/home/world/wrld_972f24e5-99c7-4517-9dc0-a36ba5adfcd7

ボール遊びの概要

ビーチボールを投げるとNPCのラスクちゃんがボールを追いかけて、拾って、持ってきてくれるものです。こう書くととても簡単そうですよね。でも実際にプログラムで実装してみると、状態の変化が多くて結構複雑なものになってしまいました。

開発当初は以下の2~6の5段階のみで作っていましたが、遊び終わった後に壁のボタンを押すとラスクちゃんが消えてしまうのが味気なかったので、「外からラスクちゃんが遊びにくる」というシチュエーションにしました。遊び終わるとおうちに帰るので、ボタンを押したときの虚しさがありません。笑

10種類の状態

0.ボール遊びの有効化待ち
1.ラスクちゃんをホームポジションへ移動
2.待機(プレイヤーがボールを持ったことを検知)
3.投げたボールを追いかける(ボールを離したことを検知)
4.ボールを拾う(アニメーション再生)
5.投げた人のところまで、ボールを持って近づいてくる
6.ボールを渡す
7.なでられたらニコニコする
8.終了
9.ラスクちゃんがおうちに帰る

プログラムの説明

前回作った、ねこじゃらしで遊ぶラスクちゃんのプログラムがベースになっています。ファイル名を2→3にするという安直なやり方ですいません。

Udonsharpスクリプト

キャラクターのコントロール AICharacterControl3.cs
キャラクターのアニメーション制御 ThirdPersonCharacter3.cs
ボールの制御 BeachBall.cs

オブジェクトの関係

(0) ボール遊びの有効化待ち

ここは壁のボタンを押すと、外からラスクちゃんがやってくる、を表現するための部分です。ボタンは仮想狐のデザイン工房さんのLura’s Switchで、”Switch_Object_On” がアクティブ状態になったこと監視しています。アクティブになったらビーチボールを表示させます。

                if (_switch.activeSelf) // スイッチ押したら

今回はボール側でいろいろと制御させたかったので、コントローラー側からボール側の関数を実行させる方式にしました。Udonでは異なるクラスのメソッドを実行するのに ball.BallShow() のような記述ができないらしく、SendCustomEvent() を使う必要があります。

        GameObject ball;    // 追いかける対象のボール
        UdonBehaviour udon; // そのボールに付いてるUdon

                    // ボールを出現する
                    for (var i = 0; i < _tgroot.transform.childCount; i++)
                    {
                        ball = _tgroot.transform.GetChild(i).gameObject;
                        if (ball != null)
                        {
                            udon = (UdonBehaviour)ball.GetComponent(typeof(UdonBehaviour));
                            if (udon != null) udon.SendCustomEvent("BallShow");    // ボール表示
                            Vector3 pos = _ball_spawn_point.position;
                            pos.x += 0.4f * i;
                            ball.transform.position = pos;
                        }
                    }

_tgroot は投げられるボールが入っている親オブジェクトで、その配下のボールを1つづつ処理していきます。ball にボールのオブジェクトを取得したら、そのballからUdonBehaviourを取得します。あとは udon.SendCustomEvent(“BallShow”); で、ボール側の BallShow() を実行させます。

    // ボールの表示
    public void BallShow()
    {
        //gameObject.SetActive(true);
        this.gameObject.GetComponent<Renderer>().enabled = true;
    }

(1) ラスクちゃんをホームポジションへ移動

開始前は部屋の外で待機しています。スイッチの近くまで誘導するために、agent.SetDestination() で目標をセットしています。目標までの残りの距離 agent.remainingDistance を見ることで、目標に到達したかどうかの判定ができるのですが、なぜか走るアニメーションが無いまま平行移動してしまいました。

                // ラスクちゃんをホームポジションまで誘導する
                agent.SetDestination(_rusk_home_point.position);   // 目標をセット
                agent.stoppingDistance = bstop_distance;   //0.5
                agent.speed = agent_speed_far;
                if (exec_once)
                {
                    exec_once = false;
                    break;  // SetDestination()直後はagent.remainingDistanceがすぐに計算されないので一旦戻る
                }
                // 到着判定 
                if (agent.remainingDistance > agent.stoppingDistance)
                {
                    // 目標までの距離がある場合
                    character.Move3(agent.desiredVelocity, 0);
                    ashioto(true);
                }
                else
                {   // 停止範囲内に入った場合
                    ashioto(false);
                    character.Move3(Vector3.zero, 0);
                    houchitime = 0f;
                    state++;
                }

どうやら SetDestination()で目標を設定した直後はすぐに計算されないようで、1フレーム進める必要があるようです。そこでexec_onceというフラグを作って、判定は2フレーム目から行うようにしました。

(2) 待機(プレイヤーがボールを持ったことを検知)

プレイヤーがボールを持ったら、ボールが持たれているという情報と、誰が持ったかという情報がセットされます。「誰が」というのはこのゲームのとても重要な部分で、ボールを拾ったあと、誰のところに渡しに行くかを決めるのがこの部分です。ボールにはVRC PickupとVRC Object Syncがアタッチされているので、ボールをつかんで投げたり、位置が他のプレイヤーと同期されるようになっています。

    // 同期する変数
    [UdonSynced(UdonSyncMode.None)] public bool ispick = false;

    public override void OnPickup()
    {
        // ボールのオブジェクトのオーナーを変更する
        if (!Networking.IsOwner(Networking.LocalPlayer, this.gameObject))
        {
            Networking.SetOwner(Networking.LocalPlayer, this.gameObject); // 持った人をオーナーにする
        }
        ispick = true;
        rb.velocity = Vector3.zero;
        //BallGravityOn();    // 重力有効
    }

ボールが持たれると ispick がtrueになりますが、この値をコントローラー側から ball.ispick という形で読み取ることができません。GetProgramVariable() を使って読み取る必要があります。

                        udon = (UdonBehaviour)ball.GetComponent(typeof(UdonBehaviour));
                        if (udon != null && (bool)udon.GetProgramVariable("ispick"))    // ボールを持ったら

ボールの ispick という変数は同期されていて、同じワールド内にいる人も全員同じ状態になります。なぜこうゆうことが必要かというと、前回の記事でも書きましたが、NPCはそれぞれのローカル環境で動いていため、同期して動いているものと、同期してないものの動きを合わせなければなりません。ネットワークの遅延があるので多少位置がずれたりもしますが、誰がボールを投げたとか、誰のところにボールを渡しに行くのか、という動作が同じになるようにします。

(3) 投げたボールを追いかける(ボールを離したことを検知)

ボールを離した、つまり投げたことを検出します。前のステップでやった ispick がfalseになったことを検知したら、ラスクちゃんの目標座標をボールの座標にすることで、ラスクちゃんがボールを追いかけるという動作をします。

ラスクちゃんがボールの所までたどり着き、足元にボールがあったら、ボールを拾うアニメーションを再生します。アニメーションでは、ボールを拾うシーン、ボールを持って戻ってくるシーン、ボールを渡すシーンの3種類があります。ボールの描画はアニメーション側で行っているので、元のボールは見えないようにしなくてはなりません。

なぜ投げたボールを非表示にして、アニメーション側で別のボールを見せるのかというと、同期の遅延が原因でボールがラスクちゃんについて来ないためです。これを普通にやると、手に何も持ってない状態で戻ってきて、後からボールがうにょーんと戻ってくるような現象が発生してしまいます。NPCはローカルで動作しているのに、ボールがグローバルで同期しているためです。この見た目の不自然さを解消するために、途中をアニメーション化しました。

                        // 停止範囲内に入った場合
                        ashioto(false);
                        if (check_target_near() == 2)
                        {
                            udon.SendCustomEvent("BallStop");
                            udon.SendCustomEvent("BallHide");
                            state++;
                            character.Move3(Vector3.zero, 1);   // 拾うアニメーション
                            remain = 1.1f;
                            WDCstop();
                        }

ラスクちゃんがボールの所に到達して且つ足元にボールがあったら(位置の計算方法は前回の記事を参照)、BallHide() でボールを消します。またこのままだとボールの加速がついたままで、再表示したときに吹っ飛んでいってしまうので、BallStop() で静止させています。

    // ボールの静止
    public void BallStop()
    {
        rb.velocity = Vector3.zero;
        rb.angularVelocity = Vector3.zero;
    }

(4) ボールを拾う(アニメーション再生)

ボールを拾うアニメーションを再生して、拾い終わるのを待つだけの部分です。

(5) 投げた人のところまで、ボールを持って近づいてくる

ボールを拾い終わったら、ボールを投げた人のところに戻ってきます。ここで重要になってくるのが、(2)のときに行ったオーナーの変更。ボールを持った人を、ボールのオーナーにしたのは変数を同期させるためだけでなく、誰のところに戻ってくるかという情報を保存するためでもあります。

オーナーがわかればオーナーの位置が取得できるので、それをラスクちゃんの次の目標地点に設定しています。Networking.GetOwner(ball) でボールのオーナーを取得し、owner.GetTrackingData().position でオーナーの座標が求められます。

                // ボールを投げた人(Owner)のところに戻ってくる
                ball = _tgroot.transform.GetChild(selected - 1).gameObject;
                owner = Networking.GetOwner(ball);
                if (owner != null)
                    targetpos = owner.GetTrackingData(VRCPlayerApi.TrackingDataType.Head).position; // オーナーの頭位置
                agent.SetDestination(targetpos);   // 目標をセット

(6) ボールを渡す

ラスクちゃんが無事ボールを持って戻ってきたら、ボールを渡すアニメーションを再生します。ボールを差し伸べた瞬間に、アニメーションで表示しているボールと、実際のボールを入れ替えます。ボールを受け取って一連の動作を終了するためには、手に持つことができる(VRC Pickupがついた)ボールである必要があります。実はここで色々と厄介な問題にぶつかりました。

ボールは本物のボールのように、投げたり重力に従って落下したりします。しかしボールを渡すときはまだラスクちゃんが持っているので、ボールは静止していなければなりません。そこで考えたのは、RigidBodyでUse Gravityをオフにして、Is Kinematicをオンにする方法。ところがプログラム上でこのように設定しても、次のフレームに進むとボールが落ちてしまいました。どうやらボールに付けたVRC Pickupコンポーネントが初期状態を上書きしてしまうぽいです。

そこでコライダーのついた透明な板をボールの下に置いて、ボールが落下しないようにしました。これでボールは静止しているように見えます。ところがまた問題が!?

見えない板にコライダーがあるせいで、プレイヤーが引っかかってしまったり、コライダーの上に乗ってしまうのです。場合によっては吹っ飛びます。笑 いろいろ調べたところ、プレイヤーと衝突判定を行わないレイヤーというものがあることがわかりました(参考→Layers)これを見ると Walkthrough というレイヤーは Player と干渉しないようなので、この板のレイヤーを Walkthrough に設定してみました。これで無事、コライダーに引っかかることもなく、ボールも落ちないようにできました。

また、ここでもまた謎な現象が起こりました。一定時間内にボールを受け取らないとボールを床に落とすのですが、板を非アクティブにしてもボールが空中に静止してしまいます。なぜか重力に従ってくれません。そこで床に向けてボールを押すことで、ボールを落とすという処理を入れてます。

                    _myita.SetActive(false); // 見えない板をオフ
                    ball.GetComponent<Rigidbody>().AddForce(Vector3.down);  // ボールが落ちないバグ対策

最後はボールを受け取ったか、なでなでされたか、ボールを取らずに10秒経過したか、壁のスイッチをオフにすると次のステップに進みます。

(7) なでられたらニコニコする

ちゃんとボールを持ってきたら褒めてあげたい、なでられたら喜びたい、ができるようにしました。なでられたことを検出するために、ラスクちゃんの頭と、プレイヤーの手の距離を求めて、一定距離内に入ったらなでられてると判定します。

        // ラスクちゃんのなでなでポイント(頭)とプレイヤーの手の距離を計算する
        if (selected > 0)   // ボールを投げた後は投げた人をチェックする
        {
            GameObject ball = _tgroot.transform.GetChild(selected - 1).gameObject;
            VRCPlayerApi owner = Networking.GetOwner(ball);
            if (owner != null)
            {
                distance_r = Vector3.Distance(owner.GetBonePosition(HumanBodyBones.RightHand), _nadePosition.position);
                distance_l = Vector3.Distance(owner.GetBonePosition(HumanBodyBones.LeftHand), _nadePosition.position);
                if (Mathf.Min(distance_r, distance_l) < nadenade_distance) naderemain = nadenade_time;
            }
        }
        else // それ以外はローカルユーザーを見る
        {
            distance_r = Vector3.Distance(Networking.LocalPlayer.GetBonePosition(HumanBodyBones.RightHand), _nadePosition.position);
            distance_l = Vector3.Distance(Networking.LocalPlayer.GetBonePosition(HumanBodyBones.LeftHand), _nadePosition.position);
            if (Mathf.Min(distance_r, distance_l) < nadenade_distance) naderemain = nadenade_time;
        }

なでられる位置は頭の上部にするために、頭のボーンではなく、空のオブジェクトを頭の中に設置しました。ただし頭や体は視線追従で動くので、頭のボーン(Head)の中に入れてあります。これで頭が動いても位置が追従します。次に触る方の手は、GetBonePosition()で取得。右手左手それぞれの近い方の距離が、一定の範囲内にあるか判定します。

ここでも戻ってくるとき同様に、ボールのオーナーを対象にしています。ここはプレイヤー全員でもよかったんですが、そうすると1フレームごとにワールド内にいる全員の位置を計算することになり、そこで負荷が上がっても困りますし、そこまでやらなくてもいいかなぁという感じです。あと待機時はローカルプレイヤーには反応するようになってます。

(8) 終了

待機状態(2)に戻ります。視線追従している状態から急激に顔が動かないように、少しの間待機するだけの箇所です。

(9) ラスクちゃんがおうちに帰る

ボタンをオフにした瞬間、目の前にいたラスクちゃんが消滅するのはちょっと味気ない、ということでおうちに帰るのがこの箇所です。おうちの場所はドアの外側にあり、ドアの外までラスクちゃんを誘導します。おうちに着いたらボールを消して、(0) に戻ります。

プログラムそのほか

しばらくしたら勝手に帰る

30秒間遊んでくれなかったら勝手に帰ることにしました。笑 (2)で houchitime をカウントしていき、30秒経ったら (9) に移行します。ただしこの場合はボールは消さず、再びボールを持てば近づいてくるようにしました。

Wacth Dog Timer

Wacth Dog Timer (WDC)というのは、マイコンなどで機器が正常に作動しない場合に自動的にリセットする機能です。このワールドで考えられるのは、ボールが想定外の場所に行ってラスクちゃんがたどり着けないケースや、プレイヤーがボールを受け取る前にワールドから抜けて戻り先が無いようなケースが考えられます。この場合無限ループに陥ってしまうので、ボールを投げてからたどり着くまでに30秒以上かかったら、強制リセットをすることにしました。

    // Watch Dog(Cat?) Timer 行動不能に陥ったラスクちゃんを救助
    private void WDCcheck()
    {
        if (!wdc_on) return;
        wdctimer -= Time.deltaTime;
        if (wdctimer < 0f)
        {
            wdc_on = false;
            Debug.Log("[Watch Dog Timer] Auto Reset!!");
            Reset();
        }
    }
    private void WDCstart(float wdcsec)
    {
        wdc_on = true;
        wdctimer = wdcsec;
    }
    private void WDCstop()
    {
        wdc_on = false;
    }

WDCを開始したい場所で WDCstart() でタイマーをスタートし、検出したい場所に WDCcheck() を入れることで、一定時間以上その間を動いている場合に Reset() を実行することになってます。

表情の変更

表情の変更はシェイプキーを操作することで実現しています。ねこじゃらし版の ThirdPersonCharacter2.cs ではアニメーションを操作する関数 Move2() と連動して動作していましたが、今回は新たに Emote() という関数を独立させました。

	string[] shapekeys_nico = new string[] { "smile", "mayu_down", "kuchi_wa", "cheek" };   // 目(ニコニコ)口(ワ)
	string[] shapekeys_nico_rev = new string[] { "kuchi_pokan" };
	string[] shapekeys_ratio_name = new string[] { "mayu_down", "kuchi_wa" };    // シェイプキーの適用比率調整
	float[] shapekeys_ratio_value = new float[] { 0.5f, 0.8f };    // その倍率

	// 表情を更新する
	public void Emote(int place, float weight)
	{
		mabataki_on = true;
		kuchi_on = true;
		switch (place)
		{
			// 省略
			case 3: // 目(ニコニコ)口(ワ)
				foreach (string name in shapekeys_nico) SetShapekey(name, weight);
				foreach (string name in shapekeys_nico_rev) SetShapekey(name, 0f);
				mabataki_on = (weight <= 0f);
				kuchi_on = false;
				break;
		}
	}

	// シェイプキーを設定する
	private void SetShapekey(string shapekeyname, float weight)
	{
		int idx = _Face.sharedMesh.GetBlendShapeIndex(shapekeyname);
		if (idx >= 0)
		{
			for (var i = 0; i < shapekeys_ratio_name.Length; i++)
				if (shapekeyname == shapekeys_ratio_name[i]) weight *= shapekeys_ratio_value[i];
			_Face.SetBlendShapeWeight(idx, weight);
		}
	}

シェイプキーはオンかオフかではなく、0f~100fまでの値が設定できるようになってます。場所によっては100fにするとおかしくなったり、中間の値の方がよい場合もあります。そこで shapekeys_ratio_name , shapekeys_ratio_value で調整できるようにしてます。連想配列とか使えるなら(知らないだけ)もっとイケてる書き方ができるんでしょうけど、まぁほんのちょっといじりたいだけなので適当に済ませました。

今回の反省点

いや、もう反省点をあげたらきりがないんですが、キャラクターのアニメーション制御側(ThirdPersonCharacter3.cs)でやるべきことを、コントローラー側(AICharacterControl3.cs)でやっちゃってるところが多々あります。たとえばニコニコする表情をさせる場合は、コントローラーから Emote() を1回呼び出すだけでやるべきなのに、ゆっくり表情が変わるようにweightの値を変えながらループをさせています。

こうゆうことを考えなくていいようにするために、2種類のプログラムに分かれているのに、後からあれこれ追加していくうちにぐちゃぐちゃになってしまいました。でもまぁとにかく動いてるからヨシ!

VRChatのワールド制作は面白い!

一応これで何とか動くものが作れました。絵心が無くて3Dモデルの作成もできないような人間でも、アセットを組み合わせてこうゆうものが作れるというのは面白いですね。しかも単独のアプリとかだと、それを誰かにプレイしてもらうだけで一苦労です。でもVRChatなら、VRChatをインストールしている人なら誰でもアクセスができます。これって滅茶苦茶大きいんじゃないでしょうか。

これを見て、自分もワールドを作ってみよう!と思っていただけたら幸いです。

LINEで送る
Pocket