▶️

UdonSharp向け 安定・安全な同期型ワールド進行制御クラスを作成しました

に公開

はじめに

VRChatで複数人プレイ用のワールドを作っていると、進行制御や状態同期の問題に悩むことが多くあります。

とくに
 ❌ 複数人同時操作で状態が崩れる
 ❌ 後から入った人(LateJoiner)が進行状況を引き継げない
といった問題は、処理を作成するうえで大きな壁になります。

今回はこれらを解決する 安定・安全な同期型ワールド進行制御クラス を作成したので紹介します。


進行制御における同期通信について

ワールドの進行制御や状態同期を設計する際、UdonSyncSendCustomNetworkEvent
特性を正しく理解することはとても重要です。それぞれの仕組みにはメリット・デメリットがあり、使い分けを誤ると同期ミスや進行不具合が発生することがあります。

以下に、それぞれの特性を整理します。

UdonSync の特性

  • ✅オブジェクトの状態を全プレイヤー間で同期できる
  • ✅LateJoiner も現在の状態を取得できる
  • ❌値更新にSetOwnerする必要があり、複数人で操作を行うと値の増減が正しく同期されず、巻き戻りや他人の操作が消えるといった現象が発生します

SendCustomNetworkEvent の特性

  • ✅任意のイベント(メソッド呼び出し)をプレイヤーに送信できる
  • ✅複数人で操作を行った場合でも動作が安定している
  • ❌LateJoinerは過去に実行したイベントを取得できない

今回の実装では、UdonSyncによる値同期・LateJoiner対応を行いつつ、SendCustomNetworkEvent を使って複数人操作を安定させるようにしています。


SetOwnerは使わない

UdonSyncで値を更新するには、SetOwnerを使って値を書き込む権限(Owner)を持つ必要があります。しかし、複数人が短い間隔で操作し、交互にSetOwnerを呼び出すと、権限の切り替えが競合し、最新の値が正しく同期されなかったり、操作が巻き戻る現象が発生します。

これは、UdonSyncが値の差分ではなくOwnerが持つ最新の値そのものを同期する仕組みであるため、Ownerの切り替えや同期遅延が発生すると、切り替わった後のOwnerが持つ値が最新として上書きされてしまうためです。

そのため、複数人が操作する可能性のあるギミックでは、各プレイヤーごとにSetOwnerして値を更新せず、Ownerに設定されたプレイヤーのみが値を更新することで、同期処理の安定性が向上します。

安全性の向上

作成したクラスでは、OnPostSerializationOnDeserialization で同期状態が送受信完了したタイミングで進行処理を行うようにしています。これにより、SendCustomNetworkEvent が正常に動作しなかった場合にSendCustomNetworkEventを再送できるようにしています。

なお、公式ドキュメントでは SendCustomNetworkEvent は「確実に処理される」と記載されていますので、この処理は保険的なものですが、念のため実装しています。


基底クラス

ここからは実際の基底クラスと簡単な使い方を紹介します。
このクラスを継承することで、進行制御や状態管理の共通処理を簡単に拡張できます。

主に使用するメソッド

  • SendStep()
    進行状態を進めるリクエストを送信します。内部的には SendCustomNetworkEvent を使用し、オーナーが進行処理を行います
  • SendRestore()
    進行状態を初期化するリクエストを送信します。

override 可能な主なメソッド

  • IsUnlocked
    現在進行がアンロックしているかを判定する条件
  • Initialize()
    初期化時に呼ばれる処理
  • StepValue()
    同期変数を変更する処理
  • RestoreValue()
    同期変数を初期化する処理
  • OnUnlock()
    進行が完了したときに呼ばれる処理(例:ドアを開ける)
  • OnRestore()
    進行が初期化されたときに呼ばれる処理(例:ドアを閉じる)

ソースコード

SafeProgressSyncSystem.cs
using System;
using UdonSharp;
using UnityEngine;
using VRC.Udon.Common;
using VRC.Udon.Common.Interfaces;

// 進行状態(アンロック状態)を安全に同期・制御するための基盤クラス
[UdonBehaviourSyncMode(BehaviourSyncMode.Manual)]
public abstract class SafeProgressSyncSystem : UdonSharpBehaviour
{
    private DateTime _lastSendTime = DateTime.MinValue;
    // SendCustomNetworkEventの処理間隔
    [SerializeField] private double _sendIntervalSeconds = 0.1;
    
    // アンロック条件チェック
    protected virtual bool IsUnlocked => false; 
    private bool _prevIsUnlocked = false;

    private void Start()
    {
        Initialize();
        UpdateProgress();
    }
    
    // 進行ステップを1つ進める(Unlock状態なら何もしない)
    public void Step()
    {
        if (IsUnlocked)
        {
            return;
        }

        StepValue();
        RequestSerialization();

#if UNITY_EDITOR
        // エディタ上ではOnPostSerializationを擬似的に呼ぶ
        OnPostSerialization(new SerializationResult(true, 0));
#endif
    }

    // 進行状態を初期値に戻す
    public void Restore()
    {
        RestoreValue();
        RequestSerialization();

#if UNITY_EDITOR
        // エディタ上ではOnPostSerializationを擬似的に呼ぶ
        OnPostSerialization(new SerializationResult(true, 0));
#endif
    }

    public override void OnPostSerialization(SerializationResult result)
    {
        if (result.success)
        {
            UpdateProgress();
        }
        else
        {
            // 失敗時の再送処理
            RequestSerialization();
        }
    }

    public override void OnDeserialization(DeserializationResult result)
    {
        UpdateProgress();
    }

    // 進行状態を更新メソッド
    private void UpdateProgress()
    {
        // 状態に変化がない場合は何もしない
        if (IsUnlocked == _prevIsUnlocked)
        {
            return;
        }

        if (IsUnlocked)
        {
            OnUnlock();
        }
        else
        {
            OnRestore();
        }

        _prevIsUnlocked = IsUnlocked;
    }

    // 進行の送信
    protected void SendStep()
    {
        // 送信インターバル時間経過している場合送信する
        var nowTime = DateTime.Now;
        if ((nowTime - _lastSendTime).TotalSeconds >= _sendIntervalSeconds)
        {
            SendCustomNetworkEvent(NetworkEventTarget.Owner, nameof(Step));
            _lastSendTime = nowTime;
        }
    }

    // 進行初期化の送信
    public void SendRestore()
    {
        var nowTime = DateTime.Now;
        if ((nowTime - _lastSendTime).TotalSeconds >= _sendIntervalSeconds)
        {
            SendCustomNetworkEvent(NetworkEventTarget.Owner, nameof(Restore));
            _lastSendTime = nowTime;
        }
    }

    protected virtual void Initialize() { }
    protected virtual void StepValue() { } // 値の更新
    protected virtual void RestoreValue() { } // 値の初期化
    protected virtual void OnUnlock() { } // コンテンツのアンロック
    protected virtual void OnRestore() { } // コンテンツの初期化
}

使用例 (ドアを専用鍵を使って開錠する)

実装サンプル
この基底クラスを使った具体例として、専用の鍵オブジェクトを使ってドアを開閉するシーンを実装しました。

この例では鍵を所持したプレイヤーが差し込み口に接触するとドアが開く処理となります。

以下にその実装コードを掲載します。

ドアのロック・開錠
using UdonSharp;
using UnityEngine;
using VRC.SDKBase;

public class ProgressingDoorLock : SafeProgressSyncSystem
{
    [SerializeField] private GameObject _door; // 対象のドアオブジェクト
    [SerializeField] private ProgressUnlockKey _key; // 使用する鍵オブジェクト
    
    // 同期変数
    [UdonSynced] private bool _isUnlocked;
    
    // アンロック判定
    // 同期変数 _isUnlockedがtrueの時アンロック
    protected override bool IsUnlocked => _isUnlocked;

    protected override void Initialize()
    {
        _key.InitializeIfNeeded();
    }

    protected override void StepValue()
    {
        _isUnlocked = true;
    }

    protected override void RestoreValue()
    {
        _isUnlocked = false;
    }

    protected override void OnUnlock()
    {
        // ドアがアンロックされたときの処理(ドアを非表示にし、鍵も非表示にする)
        _door.gameObject.SetActive(false);
        _key.OnUnlock();
    }

    protected override void OnRestore()
    {
        // ドアを表示、鍵も初期化
        _door.gameObject.SetActive(true);
        _key.OnRestore();
    }

    // 鍵穴に鍵が接触したときの処理
    private void OnTriggerEnter(Collider other)
    {
        // 条件を満たさない場合は無視(既にアンロック済み or 無効な鍵 or 他プレイヤー持つ鍵)
        ProgressUnlockKey key = null;
        if (_isUnlocked || 
            !Utilities.IsValid(other) ||
            !Utilities.IsValid(key = other.GetComponent<ProgressUnlockKey>()) ||
            !key.Equals(_key) ||
            !key.IsOwner()
            )
        { 
            return;
        }
        
        SendStep();
    }

}
専用鍵
using UdonSharp;
using UnityEngine;
using VRC.SDK3.Components;
using VRC.SDKBase;

[UdonBehaviourSyncMode(BehaviourSyncMode.None)]
public class ProgressUnlockKey : UdonSharpBehaviour
{
    [SerializeField] private VRCPickup _pickup;
    
    private bool _initialized;
    private Vector3 _startPos;
    private Quaternion _startRot;

    private void Initialize()
    {
        // 初期位置・回転を記録
        var t = _pickup.transform;
        _startPos = t.position;
        _startRot = t.rotation;
    }

    public void InitializeIfNeeded()
    {
        if (!_initialized)
        {
            Initialize();
            _initialized = true;
        }
    }

    public void OnUnlock()
    {
        // オーナーであれば持っている鍵をDrop
        if (Networking.IsOwner(_pickup.gameObject))
        {
            _pickup.Drop();
            RestorePosition();
        }

        gameObject.SetActive(false);
    }

    public void OnRestore()
    {
        if (Networking.IsOwner(_pickup.gameObject))
        {
            RestorePosition();
        }
        
        gameObject.SetActive(true);
    }

    private void RestorePosition()
    {
        InitializeIfNeeded();
        _pickup.transform.SetPositionAndRotation(_startPos, _startRot);
    }

    public bool IsOwner()
    {
        return Networking.IsOwner(_pickup.gameObject);
    }

}

まとめ

今回紹介した安全な進行制御クラスは、複数人参加やLateJoinerの参加による同期崩れを防ぎ、安定したマルチプレイ体験を実現するための基盤となります。

ポイントを振り返ると:

  • ✅UdonSync を使った状態同期で LateJoiner にも対応
  • ✅SendCustomNetworkEvent を使った安定した進行トリガーの実行
  • ✅OnPostSerialization と OnDeserialization で進行状態を確実に反映
  • 🛡️トリガー再送可能で、予期しない同期失敗の保険を用意

今後はこの基盤を応用して、ボス戦、協力型パズル、ギミック解除、イベントシーン管理など、さまざまな進行管理コンテンツに展開できると考えています。

もし実際に導入したり試した方がいれば、ぜひフィードバックを教えてください!
質問や改善案もお待ちしています。

Discussion