はじめに
最近、Unityネットワーク通信ライブラリであるMirrorを覚えて使い勝手の良さに心を奪われつつある @yoship1639 です。
アクションゲームなどでよくある「動く床」ですが、シングルプレイであれば誰でも簡単に実装はできる初心者向けのギミックですね。
そんな動く床ですが、ネットワークマルチプレイとなると少し話が変わります。初心者では難しいのが、動く床のホストとクライアントとの座標の完全一致です。
これを難なくクリアできる人は多分業界の人ですが、知識がないと完全に位置同期させる事はできません。
どうすれば座標を完全に同期させる事ができるか、Unityネットワーク通信が抱える問題点を交えながら解説したいと思います。
因みに今回の動く床というのは、「何もしなくても時間に沿って一定の法則で動く床」です。
また、通信ライブラリにはMirrorを使いますが、Mirrorの使い方自体は解説しませんのでご了承ください。
位置同期の様々な課題
早速ですが、動く床の位置同期がちょっと難しい理由を簡単に説明します。
まず1つ目は通信遅延です。いわゆるラグですね。ネットワークは通信するために少し時間がかかります。そのため、ホストからクライアントに動く床の座標を安直に送信すると100%座標がずれます。ホスト側の座標を送信したタイミングとクライアントが受け取ったタイミングに時間差があるためですね。
これを無視したまま実装すると、ホスト側は問題なく動いてるのにクライアント側は少し遅れて動くので、動く床の上にキャラクタやらオブジェクトが乗っていると動く床からはみ出て宙に浮いているのに落ちないみたいな見た目になります。
それなら、動く床の座標がずれるならばホストからクライアントに座標を送るのを止めてそれぞれが動く床を処理すればいいのでは?という考えになりますが、それでもまだ課題はあります。
2つ目はホストとクライアントの開始時間が違うための同期ずれです。ホストとクライアントが別々に動く床を動かすとホストは毎回位置を送信しなくても良くなりますが、それでも座標はずれます。様々な理由からゲームが始まるタイミングをホストとクライアントで全く同じにする事ができないためです。
ではどうすればいいのか。答えはただ一つ、ホストとクライアントの経過時間を完全に一致させることです。時間に沿って動く床であれば、時間さえホストとクライアントで完全に一致すればこの問題は解決します。
動く床の座標を完全同期させる
ホストとクライアントで時間を完全に一致させるのは難しいのではないかと思うかもしれませんが、実は結構簡単です。
ホストとクライアントのゲーム(シーン)の開始時刻を同期させるためには、以下の手順を踏めばいいだけです。(手順を表すだけの仮のコードです)
- ホスト側
private float baseTime;
void Start()
{
// 現在時刻を基準時刻とする (DateTime.UtcNowでもOK)
baseTime = Time.realtimeSinceStartup;
// クライアントにホストの現在時刻を送信する
RpcSetBaseTime(DateTime.UtcNow);
}
void Update()
{
// ホスト開始からの経過時間
var t = Time.realtimeSinceStartup - baseTime;
// tを使って動く床の位置を指定、と仮定
SetPosition(t);
}
変数tがホストとクライアントで一致すれば完全な位置同期となります。
クライアント側はRpcで来たホストの時刻を元に、通信ラグを計算します。
- クライアント側
private float baseTime;
// ホストからRpcSetBaseTimeが呼ばれたと仮定
void RpcSetBaseTime(DateTime hostTime)
{
// 通信で起きたラグを算出
var delay = (float)(DateTime.UtcNow - hostTime).TotalSeconds;
// ラグを考慮した開始時間を設定、このbaseTimeがホストの基準時刻と一致する
baseTime = Time.realtimeSinceStartup - delay;
}
void Update()
{
// tはホスト側のtと一致する
var t = Time.realtimeSinceStartup - baseTime;
// tを使って動く床の位置を指定、と仮定
SetPosition(t);
}
通信で起きたラグ分だけホスト側は床が移動している事になるので、その分だけクライアント側が補正を掛けて基準時刻を決定します。
これにより、経過時間である変数tはホストとクライアントで完全に一致するので動く床の座標は完全に同期されます。
Time.timeを使わない理由は、Time.timeだとアプリケーションが非アクティブになった時に時間も止まってしまうためです。そうすると非アクティブになっている時間だけずれてしまいます。
ソースコード
Mirrorによる完全同期する動く床のコードは次のようになります。
(MirrorではなぜかDateTimeを正しくシリアライズする事ができなかったので、longに変換して対処しています)
using System;
using Mirror;
using UnityEngine;
public class MovePlatform : NetworkBehaviour
{
[SerializeField] private Vector3 from;
[SerializeField] private Vector3 to;
[SerializeField] private float moveTime;
float baseTime = float.MinValue;
void Start()
{
if (isServer)
{
baseTime = Time.realtimeSinceStartup;
RpcSetBaseTime(DateTime.UtcNow.ToBinary());
}
}
void Update()
{
if (baseTime == float.MinValue) return;
var t = Time.realtimeSinceStartup - baseTime;
SetPosition(t);
}
// 時間で行ったり来たりする
private void SetPosition(float time)
{
var interval = (moveTime) * 2.0f;
time = time % interval;
if (time <= moveTime)
{
var t = time / moveTime;
transform.position = Vector3.Lerp(from, to, t);
}
else
{
var t = (time - moveTime) / moveTime;
transform.position = Vector3.Lerp(to, from, t);
}
}
[ClientRpc]
void RpcSetBaseTime(long dateTime)
{
if (!isServer)
{
var delay = (float)(DateTime.UtcNow - DateTime.FromBinary(dateTime)).TotalSeconds;
baseTime = Time.realtimeSinceStartup - delay;
}
}
}
Mirrorだとサーバークライアント共通のコードで書けるのでかなりシンプルですね。
これなら毎回座標同期のための通信を行わなくて済むし座標も完全に一致しているので使い勝手が良さそうです。
おわりに
時間の完全一致問題、実はホストとクライアントがそれぞれDateTimeだけを使って床を動かせば完全に一致させることができるんじゃないかと思われたかもしれませんが、それだと動く床の始点を共有する事ができません。ゲームが始まった時点で既にある程度動いた状態の位置に動く床がいる事になります。これを許容する場合はもちろんそれが一番スムーズですが、今回はその制限に縛られない方法を記述させていただきました。
また、完全同期と謳っていますがずれるケースがあります。それはDateTimeがホストとクライアントで一致していない時です。ほとんど起こりえない事ですが、この場合正確な指標が無くなるため座標が同期ができなくなります。
今回紹介した時間同期の手法は動く床に留まらず様々な所で応用が利くので、ぜひ試していただければと思います。