NPCが動きまわるワールド『NPC Cat toy – ねこじゃらし遊び』に、ポテチをあげたり、食べられる機能を追加しました。このワールドの制作記は続編になりますので、よろしければ前編もご覧ください。

(1) NPCが動きまわるワールド「NPC Cat toy – ねこじゃらし遊び」を作ってみた
(2) NPCのボール遊び機能が想像以上に大変だった件

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

ポテチもぐもぐ機能の概要

お盆に盛られたポテトチップスをつかんで、自分で食べたり、ラスクちゃんに食べさせることができます。このポテチはBOOTHで販売されている ヨドコロちゃんのポテトチップス を使用していて、そのままワールド内に設置するだけでポテチが食べられるようになるというスグレモノです。今回はそのポテチを、NPCのラスクちゃんにもあげられるようにしました。

ポテチをつかむとラスクちゃんが近づいてきます。じーっとポテチを見つめてます。ラスクちゃんの口の近くで離すと、ポテチをもぐもぐと食べて喜びます。ボール遊び機能に追加したので、ボールで遊ぶ方のラスクちゃんが食べます。

プログラムの説明

仕組みや、工夫した箇所について説明していきたいと思います。

Udonsharpスクリプト

キャラクターのコントロール AICharacterControl3.cs
キャラクターのアニメーション制御 ThirdPersonCharacter3.cs
もぐもぐサウンドの再生 PlayOnEnable.cs
ヨドコロちゃんのポテトチップス Yodo_PotatoChip2.cs(非公開)

※Yodo_PotatoChip2.cs は有償で配布されているプログラムがベースになってるので公開はできませんが、修正内容などをこちらで書いていきます。

ポテチの仕様

説明の前に、ヨドコロちゃんのポテトチップスの仕様を理解する必要があります。上記画像左側の Yodo_PotatoChips がヨドコロちゃんポテトチップスで、下層にChipsとEfffectsという2種類のフォルダがあります。Chipsの中に入っているChips (*)は1枚1枚のポテチで、VRC Pickupがついているのでプレイヤーが持つことができます。

持っていたポテチを離すと、パリッという音がして食べる動作になります。ポテチを離すと、ポテチはお盆の上に戻るので、繰り返し食べることが可能となっています。しかしその状態で音を鳴らすと、音はお盆から鳴ってしまいます。口のところで離したのですから、口で音が鳴らないと変ですよね。そこで食べた場所で音を鳴らすために使用しているのが、Effectsの中の CrunchiEffectSource (*)です。

CrunchiEffectSourceはピカッという特殊効果とサウンドを再生するためのオブジェクトで、Chipsを離した瞬間に同じ座標へ移動します。そのおかげでポテチはお盆に戻り、音はその場で鳴るということを実現しています。この仕様に合わせるため、NPC側のプログラムも工夫しました。

処理の流れ

ポテチをつかむ → ポテチの場所へ行く
ポテチを離す → 口の近くで離したのなら → ラスクちゃんが食べる

前回のビーチボールと似てますね。ビーチボールのときは、ボールを持つと近づいてきて、投げると取りに行き、投げた人の所に戻ってきて、ボールを渡す、という流れでした。今回は前回ほど複雑ではありません。

ポテチをつかんでる状態の判定方法

ポテチをつかんでるか、離したかという状態は、NPC側からも検知できなくてはなりません。そこでヨドコロちゃんのポテトチップス側のプログラム(Yodo_PotatoChip2.cs)に、状態を保持するための変数を設け、GetProgramVariable() を使ってポテチのオブジェクト内の変数を読み込んでチェックしてます。

    // 同期する変数
    [UdonSynced(UdonSyncMode.None)] public bool ispick = false; // ポテチを持っている
    [UdonSynced(UdonSyncMode.None)] public int effidx = -1; // 再生したエフェクトの番号

    public override void OnPickup()
    {
        ispick = true;
        effidx = -1;
        //以下略
        }
    }

    public override void OnDrop()
    {
        ispick = false;
        //以下略
    }

この辺は前回のビーチボールと同じやり方です。ポテチを持つと変数 ispick がtrueになるので、これをチェックすれば状態がわかります。以下がNPC側で、つかんでるポテチがどれかをスキャンしてる箇所です。

    // 掴んだポテチの番号を返す
    private int scan_potechi(int tbl)
    {
        int no = -1;
        GameObject yodoroot = _ObonRoots[tbl].transform.Find("Yodo_PotatoChips").gameObject;
        if (yodoroot == null) return -1;
        GameObject yodochips = yodoroot.transform.Find("Chips").gameObject;
        if (yodochips == null) return -1;
        //float distance = 999f;
        for (var i = 0; i < yodochips.transform.childCount; i++)
        {
            GameObject chips = yodochips.transform.GetChild(i).gameObject;
            if (chips != null)
            {
                UdonBehaviour udon = (UdonBehaviour)chips.GetComponent(typeof(UdonBehaviour));
                if (udon != null && (bool)udon.GetProgramVariable("ispick"))    // ポテチを持ったら
                {
                    //float tmpdistance = Vector3.Distance(this.transform.position, chips.transform.position);
                    //if (tmpdistance < distance)
                    //{
                        no = i;
                    //    distance = tmpdistance;
                    //}
                    break;
                }
            }
        }
        return no;
    }

最初の _ObonRoots[] は、先ほどのヒエラルキーウィンドウ内のツリー内にある「ポテチn」が入っています。この中から下層にあるChipsオブジェクトまで辿っていき、Chips内を1つ1つ GetProgramVariable() を使って中の変数 ispick を取得してます。これでtrueになっているポテチがあったら、何番目のポテチがつかまれているかを返します。

ところでこの scan_potechi() 関数には tbl という引数が付いてます。これはテーブル番号で、実はポテチのお盆はワールド内に3つ置かれているのです。そのため、どのお盆か?という情報も必要になります。それが以下の関数です。

    // 掴んだポテチのテーブルと番号を返す(foundtable,cselected=0スタート、-1は未選択)
    private int scan_potechi_all()
    {
        int no = -1;
        int foundtable = -1;
        for (var j = 0; j < _ObonRoots.Length; j++)
        {
            int no_tmp = scan_potechi(j);
            if (no_tmp >= 0)
            {
                no = no_tmp;
                foundtable = j;
                break;
            }
        }
        ctable_return = foundtable; // UdonSharpはoutもタプルも使えないのでグローバル変数で渡す
        return no;
    }

お盆(_ObonRoots[])の数だけ scan_potechi() を実行しているだけです。これで、何番目のお盆の、何番目のポテチがつかまれているか、わかるようになりました。これを呼んでるのが、Update()の2番目(state=2)の所になります。

                    cselected = scan_potechi_all();    // ポテチをスキャン
                    ctable = ctable_return;

cselected がつかんでいるポテチの番号、ctable がお盆の番号です。ctable_return はグローバル変数です。なんでこんな書き方をしているかというと、UdonSharpでは以下のような記述ができないためです。
(int a, int b) = hoge()
int a = hoge(out int b)
もっと良い方法はないですかね??

口の近くで離したかの判定

ポテチをつかんだ後は、ポテチを離して食べる動作です。今回はラスクちゃんの口の近くで離した場合のみ、ラスクちゃんが食べる動作を行い、それ以外は通常通りのヨドコロちゃんポテトチップスの動作を行います。ここが少し大変でした。

ポテチを離すと同時に、ポテチはお盆の上に戻ってしまいます。そのためラスクちゃんの口に近くにあるか判定するには、エフェクトの座標と比較しなくてはなりません。しかしポテチの番号とエフェクトの番号は一対一で対応しているわけではありません。ヨドコロちゃんのポテトチップスでは、同期処理 SendCustomNetworkEvent() を使って、食べる演出 Yodo_EatChips() が実行されます。この中では “再生中ではない” エフェクト選ばれるため、どのエフェクトが呼ばれたのかを effidx という変数に保存しておき、それをNPC側から読み込むようにしています。

    // 掴んだ後のポテチに対応するエフェクト番号を返す
    private int get_potechi_effidx(int tbl, int no)
    {
        int idx = -1;
        if (tbl < 0 || no < 0) return -1;
        GameObject yodoroot = _ObonRoots[tbl].transform.Find("Yodo_PotatoChips").gameObject;
        if (yodoroot == null) return -1;
        GameObject yodochips = yodoroot.transform.Find("Chips").gameObject;
        if (yodochips == null) return -1;
        GameObject chips = yodochips.transform.GetChild(no).gameObject;
        if (chips != null)
        {
            UdonBehaviour udon = (UdonBehaviour)chips.GetComponent(typeof(UdonBehaviour));
            if (udon != null)
            {
                idx = (int)udon.GetProgramVariable("effidx");
            }
        }
        return idx;
    }

get_potechi_effidx() はお盆とポテチの番号を指定すると、そのポテチに対応したeffidxの値を返します。あとはそのエフェクトオブジェクトの座標と、口と判定する場所(_rusk_eat_point)の座標との距離を Vector3.Distance() を使って求めることで、口の前で離したかどうかを判定できます。

                        int effidx = get_potechi_effidx(ctable, cselected); // ポテチの対応エフェクト
                        if (effidx >= 0)
                        {
                            yodoeffects = yodoroot.transform.Find("Effects").gameObject;
                            if (yodoeffects == null) break;
                            effobj = yodoeffects.transform.GetChild(effidx).gameObject;
                            if (effobj != null)
                            {
                                float distance = Vector3.Distance(_rusk_eat_point.transform.position, effobj.transform.position);
                                if (distance < 0.2) // 口の前で離した場合
                                {

頭の位置は動くので、ラスクちゃんの Armature>Spine>Chest>Neck>Headの中に、透明なダミーオブジェクトを入れ、_rusk_eat_point に紐づけています。これで顔が横に向いていても、常に口の前の判定ができます。

アニメーションの再生

やり方は前回のビーチボールのときと同じですが、今回は2種類のアニメーションを用意しました。1つは通常の「もぐもぐ→おいちー」、もう一つはテンションが上がった「もぐもぐ→うんまー!」です。どちらを再生するかは、アニメーター側の変数 MoguSelect で振り分けています。

テンションが上がったかどうかは、ねこじゃらしの時と同じ考え方で、mogupoint という変数で行ってます。

その他の変更箇所や工夫したところ

シェイプキーの変更をアニメーターで行うようにした

表情の変更をするために以前のバージョンでは、プログラム側(ThirdPersonCharacter3.cs)でシェイプキーを制御していました。目・口などのパーツは同時に動かしてはいけないものがあり、それをプログラムで制御していたのですが、数が増えてぐちゃぐちゃになってしまいました。また、シェイプキーをアニメーションで使ったところ、そのシェイプキーがプログラム側から一切制御できなくなる仕様(?)にぶち当たりました。再生してる場合ならまだわかりますが、再生どころかTransitionで接続してない状態であっても、存在するだけで制御ができなくなります。そんな理由もあって、表情の変更はアニメーターで行う方式に変えました。

目のシェイプキー
口のシェイプキー
ほっぺのシェイプキー

このおかげで、アニメーション制御(ThirdPersonCharacter3.cs)内の表情を変更する Emote() がすっきりしました。

	// 表情を更新する
	public void Emote(int place)
	{
		if (debug) return;
		switch (place)
		{
			case 0: // なし
				_Animator.SetInteger("SK_me", 0);
				_Animator.SetInteger("SK_kuchi", 0);
				_Animator.SetInteger("SK_hoppe", 0);
				break;
			case 1: // 目(しいたけ)
				_Animator.SetInteger("SK_me", 2);
				break;
			case 2: // 口(△)
				_Animator.SetInteger("SK_kuchi", 3);
				break;
			case 3: // 目(ニコニコ)口(ワ)
				_Animator.SetInteger("SK_me", 1);
				_Animator.SetInteger("SK_kuchi", 1);
				_Animator.SetInteger("SK_hoppe", 1);
				break;
		}
	}

しっぽふりふり、耳ぴょこぴょこ

しっぽと耳が勝手に動くようにしました。今回はじめてアニメーションカーブを使用しました。特にしっぽは等速で動いていると生き物らしさがありません。猫が落ち着いているときのしっぽの動き、ぬ~んぺたん、ぬ~んぺたんを表現するために、カーブで加速度を調整してみました。アニメーションて位置だけでなく、スピードで全然変わるんですね。

耳もたまに動かします。プログラム側での制御はなく、普通にアニメーションを流しているだけです。アバターマスクを使って、その部位だけを反映させるよう設定しました。

まばたきは目が開いている状態しかできません。上にある目のシェイプキーの図の sk_me_blink (animation) というのが、まばたきを行うアニメーションです。今まではプログラム側でタイミングを制御していましたが、今回からは全部アニメーションになりました。アニメーターの変数 SK_me=0 のときに、まばたきをするようになっています。

もぐもぐサウンド

ラスクちゃんがポテチを食べてるときの音は、ヨドコロちゃんのポテトチップスに入っている4種類のサウンドを元に編集させていただきました。サウンドの編集はAudacityというフリーウェアを使用しました。なかなか便利なソフトです。

サウンドはあるオブジェクト(ポテチ with サウンド)がアクティブになったら再生するよう PlayOnEnable.cs で行っており、そのオブジェクトをアニメーション内で ON/OFF することで、サウンドの再生タイミングをコントロールしています。

テーブルのコライダを変更

ソファーの前にあるテーブルにポテチを配置したのですが、ポテチを取ろうとするとテーブルに乗り上げてしまいます。きっとフルトラだったら問題ないんでしょうけど、3点トラッキングのQuestでは頭の位置で足の位置を推定するので、頭が近づくと乗り上げてしまうのです。コライダを外したいところではありますが、そうするとボールやルンバのような当たり判定がある物も通り抜けてしまいます。

そこでプレイヤーは通り抜けられるよう、レイヤーをWalkthroughに変更しました。またラスクちゃんがテーブルの上に乗るのもお行儀がよくないので、NavMeshの設定でテーブルをNot Walkableに設定してNavMeshを再ベイクしました。

ポテチの方向を向かせたい

このテーブルに乗れない仕様にしたところで、ラスクちゃんが進んだ方向から向きを変えない不自然さが目立つようになりました。ポテチの所に近づいてきたのに、体は横を向いているという状態です。そこで、ポテチまでの距離が50cm以上あって、ラスクちゃんの正面から見たポテチの角度が90度以上あったときは、体ごとポテチの方を向けることにしました。角度や距離で制限したのは、ポテチが欲しくて体を曲げる仕草も可愛いからです。

                            // 目標まで90°以上ずれ且つ50cm以上離れていたら方向を回転する
                            float tgtangle = get2DSignedAngle(this.transform.position, targetpos);
                            float tgtdistance = get2DDistance(this.transform.position, targetpos);
                            if (turnremain == 0f && Mathf.Abs(tgtangle) >= 90f && tgtdistance > 0.5f) turnremain = -tgtangle;
                            if (turnremain != 0f)
                            {
                                float descangle = 0f;
                                if (Mathf.Abs(turnremain) < 10f) descangle = -turnremain;
                                else descangle = (turnremain < 0f) ? 10f : -10f;
                                this.transform.Rotate(0, descangle, 0);
                                turnremain += descangle;
                                if (Mathf.Abs(turnremain) < 1f) turnremain = 0f;   // 念のため(精度誤差の無限ループ防止) 
                            }

角度と距離は、上から見た平面で計算しています。Vector3.Angle() を使うと簡単に角度が求められて便利なのですが、なぜか負の値が出てきません。このままでは右側なのか、左側なのかという判定ができません。調べてみたら Vector3.Cross() を使えばいいということで、正面から見てどちら側なのかを正負で判断できるようにしました。

    // 正面方向から見た任意点の角度(上から見た2D)
    private float get2DSignedAngle(Vector3 homepos, Vector3 targetpos)
    {
        targetpos.y = 0f;
        homepos.y = 0f;
        Vector3 targetdir = targetpos - homepos;
        Vector3 homefwddir = this.transform.forward;
        homefwddir.y = 0f;
        float angle = Vector3.Angle(homefwddir, targetdir.normalized);
        if (Vector3.Cross(homefwddir, targetdir).y < 0) angle = -angle;
        return angle;
    }

    // 2点間の距離(上から見た2D)
    private float get2DDistance(Vector3 homepos, Vector3 targetpos)
    {
        targetpos.y = 0f;
        homepos.y = 0f;
        Vector3 targetdir = targetpos - homepos;
        Vector3 ruskfwddir = this.transform.forward;
        ruskfwddir.y = 0f;
        float distance = Vector3.Distance(homepos, targetpos);
        return distance;
    }

同期対応

毎回悩ましいのが同期対応。VRchatの同期は期待したタイミングで行われないので、実際に動かすと予想をしなかったことが起こります。A→Bという順番で実行したつもりでも、B→Aという順番で同期されることもあり、不確定なものという前提で考えなくてはなりません。さらに同期されないこともあるので、どこかのタイミングで合わせるということも頭に入れておく必要があります。

ポテチの位置の同期

ポテチの位置はヨドコロちゃんのポテトチップスの方であらかじめ設定されている、VRC Object Syncをそのまま使ってます。NPCは各プレイヤーのローカルで動いているので、NPCの実際の位置や向きというのは、各プレイヤーによって異なってしまいます。しかしポテチの位置はVRC Object Syncで同期されているので、どのプレイヤーのNPCも同じような位置にいるのは確かです。

ところがここで一つ問題があります。ビーチボールの場合はボールを投げた位置まで取りに行くので、位置がどこであっても問題ありません。しかし今回は、口の近くで離したらラスクちゃんが食べる、という仕様があります。その判定距離は20cm。プレイヤーよってNPCの位置が微妙に違うので、ポテチの位置が範囲外になってしまう場合があります。同じワールドにいる人でも、もぐもぐしているラスクちゃんと、何もしてないラスクちゃんがいたら変ですよね。そこで、場所がどこであっても、食べる動作を同期実行することにしました。

                                if (distance < 0.2) // 口の前で離した場合
                                {
                                    // ポテチを掴んだ本人なら同期指示を出す(アニメーション再生)
                                    if (Networking.LocalPlayer == null) break;
                                    if (Networking.IsOwner(Networking.LocalPlayer, yodochips.transform.GetChild(cselected).gameObject))
                                    {
                                        state++;
                                        remain = 99f;   // dummy
                                        if (mogupoint > 2.0f) // 好感度によって表示アニメーションを切り替える
                                            SendCustomNetworkEvent(VRC.Udon.Common.Interfaces.NetworkEventTarget.All, nameof(update_state11_B)); // 同期
                                        else
                                            SendCustomNetworkEvent(VRC.Udon.Common.Interfaces.NetworkEventTarget.All, nameof(update_state11_A)); // 同期
                                    }
                                    break;
                                }

ポテチを食べさせた人がオーナーとなり、SendCustomNetworkEvent() を使って update_state11_*() を実行します。AとBはアニメーションの種類の違いです(引数が指定できないから)。これで同じワールドにいる人全員が、同じラスクちゃんを見ることができます。

ポテチの位置の同期タイミングがずれる問題

ポテチから手を離すと食べるアクションに移ります。ラスクちゃんの口の近くでポテチを離すと、食べたという判定になり、食べるアニメーションの再生が同期実行されます。ところがここで、同期タイミングがずれると問題が起こることがわかりました。別のプレイヤーが2Fから持ってきたポテチを1Fで食べさせたところ、ラスクちゃんが2Fへと走って行ってしまったのです。

なぜこうゆうことが起こるかというと、ヨドコロちゃんのポテトチップスの仕様で、食べたポテチはお盆に戻り、エフェクトがその場に残るという仕組みがあります。「食べた」という情報が同期される前に、「ポテチの位置情報」が先に同期されてしまったので、自分が見ているラスクちゃんは2Fへ追いかけててしまったのです。

解決方法としては、同期実行される Yodo_EatChips() の “// Reset position” 以下を分割しました。

Yodo_EatChips()の最後
        // ポテチの位置リセットを遅延実行する
        pickup.pickupable = false; // Pickup不可
        rend.enabled = false;   // 非表示(SetActive使っちゃだめ!)
        resetremain = 0.5f;
    }
    public void Yodo_ResetPosition()
    {
        pickup.pickupable = true; // Pickup可
        rend.enabled = true;   // 表示

        // Reset position
以下そのまま

そして、Update()で一定時間後に Yodo_ResetPosition() を遅延実行するようにしています。

        // 位置リセットの遅延実行
        if (resetremain > 0f)
        {
            resetremain -= Time.deltaTime;
            if (resetremain <= 0f)
            {
                Yodo_ResetPosition();
                resetremain = 0f;
            }
        }

これによってポテチが戻る時間が遅くなるので、ポテチを高速で食べると在庫不足が起こってしまいます。でもこのワールドはラスクちゃんにポテチをあげるのが目的(?)ですから、まぁいいですね。あと、この遅延方式にするのなら、エフェクトの位置を参照するためにeffidxという変数を使う必要もなかったですね。いろいろと勉強になりました。

好感度の同期

続けてポテチを食べているとラスクちゃんはテンションが上がって、腕を振ります。これはmogupointという変数で判定していて、1回食べると1加算されて、何もしないと時間とともに減っていく変数です。この変数は同期してないので、このままだと人によってmogupointがバラバラになってしまいます。それを解決するために、誰かが一度アニメーションBを再生したら、全員のmogupointがリセットされるようにしました。これで入室タイミングによって異なっていたmogupointが、ワールド内で統一されるようになりました。

おわりに

前回の更新から2か月近く経ってしまいました。その間も新しいワールドのことを考えたりしていたのですが、なかなか面白そうなアイディアが浮かびませんでした。そうだ、Blenderを覚えよう、と思い立ち始めたものの、あのわけのわからないインターフェースに何度も発狂しそうになりました。w

次はまた別の機能が追加されるか、新しいワールドになるかはわかりませんが、VRChatのワールドの制作を続けていきたいと思います。

LINEで送る
Pocket