Chapter 07

🔄 サーバー時刻の活用

o8que
o8que
2021.04.02に更新

オンラインゲームでは、時刻や時間を同期させたい場面も多いでしょう。しかし、ここでローカル時刻(System.DateTime.Nowなど)を使用してはいけません。もし各プレイヤー間でローカル時刻がずれている(またはチート目的で意図的にずらしている)と、正しい同期が行われなくなってしまう可能性があるからです。

PUN2では、各プレイヤーの環境に依存しない時刻として、サーバー時刻が取得できるようになっています。このチャプターでは、サーバー時刻をうまく活用して同期を行う方法を紹介します。

サーバー時刻の基礎

サーバー時刻の取得

現在のサーバー時刻は、PhotonNetwork.ServerTimestampからミリ秒単位で取得できます。同じルームに参加しているプレイヤーは同じゲームサーバーに接続しているので、同じタイミングで取得したサーバー時刻はほぼ同じ値になります。

int currentTime = PhotonNetwork.ServerTimestamp;

サーバー時刻を取得するプロパティにはPhotonNetwork.Timeもありますが、内部的にはPhotonNetwork.ServerTimestampをdouble型に変換しただけもので、本書では使用しません。

サーバー時刻の比較

PhotonNetwork.ServerTimestampから取得できるサーバー時刻の値は、定期的にオーバーフローが発生します。int型の最大値(2147483647)を超えると、int型の最小値(-2147483648)になって進み続け、また最大値を超えたら最小値に戻ることを繰り返しています。

\vdots \\ \;\;\; 2147483643 \\ \;\;\; 2147483644 \\ \;\;\; 2147483645 \\ \;\;\; 2147483646 \\ \;\;\; 2147483647 \\ -2147483648 \\ -2147483647 \\ -2147483646 \\ -2147483645 \\ -2147483644 \\ \vdots \\

そのためサーバー時刻を比較する場合は、値同士を直接比較すると間違った結果になることがあるので、かならず差分をとって比較する必要があります。

- if (PhotonNetwork.ServerTimestamp > endTime) {
+ if (unchecked(PhotonNetwork.ServerTimestamp - endTime) > 0) {
      Debug.Log("終了時刻を過ぎました");
  }

必須ではありませんが、サーバー時刻の加算や減算では、C#のunchecked演算子を使って明示的にオーバーフローを無視しておくと、不要な警告やエラーなどを抑えられることがあります。

🎮 弾の同期を改善しよう

弾の座標をサーバー時刻から求めよう

これまでのチャプターのサンプルプロジェクトで作成しているアバターから発射する弾(Bullet)は、RPCが実行されたタイミングで生成されて移動を開始していたため、ネットワーク上の遅延の分だけ座標がズレていました。弾を発射した時刻と座標・速度から、現在の時刻における弾の座標の計算を行うようにすると、ほとんど遅延なく弾の座標を同期できます。

Bullet.cs
 using Photon.Pun;
 using UnityEngine;

 public class Bullet : MonoBehaviour
 {
+    private Vector3 origin; // 弾を発射した時刻の座標
     private Vector3 velocity;
+    private int timestamp; // 弾を発射した時刻

     public int Id { get; private set; }
     public int OwnerId { get; private set; }
     public bool Equals(int id, int ownerId) => id == Id && ownerId == OwnerId;

+    public void Init(int id, int ownerId, Vector3 origin, float angle, int timestamp) {
         Id = id;
         OwnerId = ownerId;
+        this.origin = origin;
         velocity = 9f * new Vector3(Mathf.Cos(angle), Mathf.Sin(angle));
+        this.timestamp = timestamp;

+        // 一度だけ直接Update()を呼んで、transform.positionの初期値を決める
+        Update();
     }

     private void Update() {
+        // 弾を発射した時刻から現在時刻までの経過時間を求める
+        float elapsedTime = Mathf.Max(0f, unchecked(PhotonNetwork.ServerTimestamp - timestamp) / 1000f);
+        // 弾を発射した時刻での座標・速度・経過時間から現在の座標を求める
+        transform.position = origin + velocity * elapsedTime;
     }

     private void OnBecameInvisible() {
         Destroy(gameObject);
     }
 }

弾を発射した時刻はRPCを送信した時刻と同じなので、アバターの弾を発射するスクリプト(AvatarFireBullet)では、RPCの特別な引数のPhotonMessageInfoからSentServerTimestampを取得して、弾のスクリプトへ渡しています。

AvatarFireBullet.cs
 using Photon.Pun;
 using UnityEngine;

 public class AvatarFireBullet : MonoBehaviourPunCallbacks
 {
     [SerializeField]
     private Bullet bulletPrefab = default;

     private int nextBulletId = 0;

     private void Update() {
         if (photonView.IsMine) {
             if (Input.GetMouseButtonDown(0)) {
                 var mousePosition = Camera.main.ScreenToWorldPoint(Input.mousePosition);
                 var direction = mousePosition - transform.position;
                 float angle = Mathf.Atan2(direction.y, direction.x);

                 photonView.RPC(nameof(FireBullet), RpcTarget.All, nextBulletId++, angle);
             }
         }
     }

     [PunRPC]
+    private void FireBullet(int id, float angle, PhotonMessageInfo info) {
         var bullet = Instantiate(bulletPrefab);
+        // PhotonMessageInfoから、RPCを送信した時刻を取得する
+        int timestamp = info.SentServerTimestamp;
+        bullet.Init(id, photonView.OwnerActorNr, transform.position, angle, timestamp);
     }
 }

弾を発射した時刻にディレイをかけよう

弾の座標を現在の時刻から計算して求めるようにすると、通信にかかった時間の分だけ進んだ座標が初期座標として弾が生成されます。例えば、弾を発射する処理のRPCを受信するまでに時間がかかったり、弾の速度がかなり速かったりすると、突然何もない空間から弾が出現しているように見えることがあります。

この問題を軽減する簡単な方法は、弾を発射する処理を行ってから少し時間を置いて弾が飛ぶようにすることです。これは弾を発射した時刻を、RPCを送信した時刻から少しだけ後の時刻にすることで実現できます。

AvatarFireBullet.cs
 using Photon.Pun;
 using UnityEngine;

 public class AvatarFireBullet : MonoBehaviourPunCallbacks
 {
     [SerializeField]
     private Bullet bulletPrefab = default;

     private int nextBulletId = 0;

     private void Update() {
         if (photonView.IsMine) {
             if (Input.GetMouseButtonDown(0)) {
                 var mousePosition = Camera.main.ScreenToWorldPoint(Input.mousePosition);
                 var direction = mousePosition - transform.position;
                 float angle = Mathf.Atan2(direction.y, direction.x);

                 photonView.RPC(nameof(FireBullet), RpcTarget.All, nextBulletId++, angle);
             }
         }
     }

     [PunRPC]
     private void FireBullet(int id, float angle, PhotonMessageInfo info) {
         var bullet = Instantiate(bulletPrefab);
+        // 弾を発射した時刻に50msのディレイをかける
+        int timestamp = unchecked(info.SentServerTimestamp + 50);
         bullet.Init(id, photonView.OwnerActorNr, transform.position, angle, timestamp);
     }
 }

これは入力の応答速度とのトレードオフになります。ディレイをかけすぎると、ゲームの操作性が悪くなってしまう可能性があるので、調整には細心の注意が必要です。

🎮 ゲームの経過時間を同期しよう

ゲームの開始時刻や終了時刻、経過時間や残り時間などを同期する場合に、サーバー時刻が便利に使えます。ここではゲームの経過時間を同期し表示してみましょう。ルームのカスタムプロパティでゲームの開始時刻を同期すると、現在のサーバー時刻とゲームの開始時刻との差分から経過時間が求められるようになります。

開始時刻のカスタムプロパティを定義しよう

ルームのカスタムプロパティに、ゲームの開始時刻を設定・取得する拡張メソッドを定義します。

GameRoomProperty.cs
using ExitGames.Client.Photon;
using Photon.Realtime;

public static class GameRoomProperty
{
    private const string KeyStartTime = "StartTime";

    private static readonly Hashtable propsToSet = new Hashtable();

    // ゲームの開始時刻が設定されていれば取得する
    public static bool TryGetStartTime(this Room room, out int timestamp) {
        if (room.CustomProperties[KeyStartTime] is int value) {
            timestamp = value;
            return true;
        } else {
            timestamp = 0;
            return false;
        }
    }

    // ゲームの開始時刻を設定する
    public static void SetStartTime(this Room room, int timestamp) {
        propsToSet[KeyStartTime] = timestamp;
        room.SetCustomProperties(propsToSet);
        propsToSet.Clear();
    }
}

開始時刻を設定しよう

SampleSceneのスクリプトで、ゲームの開始時刻を設定しましょう。ルームを作成したプレイヤーのみが、ルームに参加した時にマスタークライアントになることを利用して、最初に一度だけゲームの開始時刻を設定するようにします。


SampleScene.cs
 using Photon.Pun;
 using Photon.Realtime;
 using UnityEngine;

 public class SampleScene : MonoBehaviourPunCallbacks
 {
     private void Start() {
         PhotonNetwork.NickName = "Player";

         PhotonNetwork.ConnectUsingSettings();
     }

     public override void OnConnectedToMaster() {
         PhotonNetwork.JoinOrCreateRoom("Room", new RoomOptions(), TypedLobby.Default);
     }

     public override void OnJoinedRoom() {
         var position = new Vector3(Random.Range(-3f, 3f), Random.Range(-3f, 3f));
         PhotonNetwork.Instantiate("Avatar", position, Quaternion.identity);

+        // ルームを作成したプレイヤーは、現在のサーバー時刻をゲームの開始時刻に設定する
+        if (PhotonNetwork.IsMasterClient) {
+            PhotonNetwork.CurrentRoom.SetStartTime(PhotonNetwork.ServerTimestamp);
+        }
     }
 }

経過時間を表示しよう

シーン上にゲームの経過時間を表示するUIを作成して、経過時間を更新するスクリプトを追加します。


GameRoomTimeDisplay.cs
using Photon.Pun;
using TMPro;
using UnityEngine;

public class GameRoomTimeDisplay : MonoBehaviour
{
    private TextMeshProUGUI timeLabel;

    private void Start() {
        timeLabel = GetComponent<TextMeshProUGUI>();
    }

    private void Update() {
        // まだルームに参加していない場合は更新しない
        if (!PhotonNetwork.InRoom) { return; }
        // まだゲームの開始時刻が設定されていない場合は更新しない
        if (!PhotonNetwork.CurrentRoom.TryGetStartTime(out int timestamp)) { return; }

        // ゲームの経過時間を求めて、小数第一位まで表示する
        float elapsedTime = Mathf.Max(0f, unchecked(PhotonNetwork.ServerTimestamp - timestamp) / 1000f);
        timeLabel.text = elapsedTime.ToString("f1");
    }
}

プロジェクトをビルドして、ゲームの経過時間が同期されていれば成功です。