Basic Networking done
This commit is contained in:
529
Assets/Mirror/Components/Experimental/NetworkTransformBase.cs
Normal file
529
Assets/Mirror/Components/Experimental/NetworkTransformBase.cs
Normal file
@@ -0,0 +1,529 @@
|
||||
// vis2k:
|
||||
// base class for NetworkTransform and NetworkTransformChild.
|
||||
// New method is simple and stupid. No more 1500 lines of code.
|
||||
//
|
||||
// Server sends current data.
|
||||
// Client saves it and interpolates last and latest data points.
|
||||
// Update handles transform movement / rotation
|
||||
// FixedUpdate handles rigidbody movement / rotation
|
||||
//
|
||||
// Notes:
|
||||
// * Built-in Teleport detection in case of lags / teleport / obstacles
|
||||
// * Quaternion > EulerAngles because gimbal lock and Quaternion.Slerp
|
||||
// * Syncs XYZ. Works 3D and 2D. Saving 4 bytes isn't worth 1000 lines of code.
|
||||
// * Initial delay might happen if server sends packet immediately after moving
|
||||
// just 1cm, hence we move 1cm and then wait 100ms for next packet
|
||||
// * Only way for smooth movement is to use a fixed movement speed during
|
||||
// interpolation. interpolation over time is never that good.
|
||||
//
|
||||
using System;
|
||||
using UnityEngine;
|
||||
|
||||
namespace Mirror.Experimental
|
||||
{
|
||||
public abstract class NetworkTransformBase : NetworkBehaviour
|
||||
{
|
||||
// target transform to sync. can be on a child.
|
||||
protected abstract Transform targetTransform { get; }
|
||||
|
||||
[Header("Authority")]
|
||||
|
||||
[Tooltip("Set to true if moves come from owner client, set to false if moves always come from server")]
|
||||
[SyncVar]
|
||||
public bool clientAuthority;
|
||||
|
||||
[Tooltip("Set to true if updates from server should be ignored by owner")]
|
||||
[SyncVar]
|
||||
public bool excludeOwnerUpdate = true;
|
||||
|
||||
[Header("Synchronization")]
|
||||
|
||||
[Tooltip("Set to true if position should be synchronized")]
|
||||
[SyncVar]
|
||||
public bool syncPosition = true;
|
||||
|
||||
[Tooltip("Set to true if rotation should be synchronized")]
|
||||
[SyncVar]
|
||||
public bool syncRotation = true;
|
||||
|
||||
[Tooltip("Set to true if scale should be synchronized")]
|
||||
[SyncVar]
|
||||
public bool syncScale = true;
|
||||
|
||||
[Header("Interpolation")]
|
||||
|
||||
[Tooltip("Set to true if position should be interpolated")]
|
||||
[SyncVar]
|
||||
public bool interpolatePosition = true;
|
||||
|
||||
[Tooltip("Set to true if rotation should be interpolated")]
|
||||
[SyncVar]
|
||||
public bool interpolateRotation = true;
|
||||
|
||||
[Tooltip("Set to true if scale should be interpolated")]
|
||||
[SyncVar]
|
||||
public bool interpolateScale = true;
|
||||
|
||||
// Sensitivity is added for VR where human players tend to have micro movements so this can quiet down
|
||||
// the network traffic. Additionally, rigidbody drift should send less traffic, e.g very slow sliding / rolling.
|
||||
[Header("Sensitivity")]
|
||||
|
||||
[Tooltip("Changes to the transform must exceed these values to be transmitted on the network.")]
|
||||
[SyncVar]
|
||||
public float localPositionSensitivity = .01f;
|
||||
|
||||
[Tooltip("If rotation exceeds this angle, it will be transmitted on the network")]
|
||||
[SyncVar]
|
||||
public float localRotationSensitivity = .01f;
|
||||
|
||||
[Tooltip("Changes to the transform must exceed these values to be transmitted on the network.")]
|
||||
[SyncVar]
|
||||
public float localScaleSensitivity = .01f;
|
||||
|
||||
[Header("Diagnostics")]
|
||||
|
||||
// server
|
||||
public Vector3 lastPosition;
|
||||
public Quaternion lastRotation;
|
||||
public Vector3 lastScale;
|
||||
|
||||
// client
|
||||
// use local position/rotation for VR support
|
||||
[Serializable]
|
||||
public struct DataPoint
|
||||
{
|
||||
public float timeStamp;
|
||||
public Vector3 localPosition;
|
||||
public Quaternion localRotation;
|
||||
public Vector3 localScale;
|
||||
public float movementSpeed;
|
||||
|
||||
public bool isValid => timeStamp != 0;
|
||||
}
|
||||
|
||||
// Is this a client with authority over this transform?
|
||||
// This component could be on the player object or any object that has been assigned authority to this client.
|
||||
bool IsOwnerWithClientAuthority => hasAuthority && clientAuthority;
|
||||
|
||||
// interpolation start and goal
|
||||
public DataPoint start = new DataPoint();
|
||||
public DataPoint goal = new DataPoint();
|
||||
|
||||
// We need to store this locally on the server so clients can't request Authority when ever they like
|
||||
bool clientAuthorityBeforeTeleport;
|
||||
|
||||
void FixedUpdate()
|
||||
{
|
||||
// if server then always sync to others.
|
||||
// let the clients know that this has moved
|
||||
if (isServer && HasEitherMovedRotatedScaled())
|
||||
{
|
||||
ServerUpdate();
|
||||
}
|
||||
|
||||
if (isClient)
|
||||
{
|
||||
// send to server if we have local authority (and aren't the server)
|
||||
// -> only if connectionToServer has been initialized yet too
|
||||
if (IsOwnerWithClientAuthority)
|
||||
{
|
||||
ClientAuthorityUpdate();
|
||||
}
|
||||
else if (goal.isValid)
|
||||
{
|
||||
ClientRemoteUpdate();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void ServerUpdate()
|
||||
{
|
||||
RpcMove(targetTransform.localPosition, Compression.CompressQuaternion(targetTransform.localRotation), targetTransform.localScale);
|
||||
}
|
||||
|
||||
void ClientAuthorityUpdate()
|
||||
{
|
||||
if (!isServer && HasEitherMovedRotatedScaled())
|
||||
{
|
||||
// serialize
|
||||
// local position/rotation for VR support
|
||||
// send to server
|
||||
CmdClientToServerSync(targetTransform.localPosition, Compression.CompressQuaternion(targetTransform.localRotation), targetTransform.localScale);
|
||||
}
|
||||
}
|
||||
|
||||
void ClientRemoteUpdate()
|
||||
{
|
||||
// teleport or interpolate
|
||||
if (NeedsTeleport())
|
||||
{
|
||||
// local position/rotation for VR support
|
||||
ApplyPositionRotationScale(goal.localPosition, goal.localRotation, goal.localScale);
|
||||
|
||||
// reset data points so we don't keep interpolating
|
||||
start = new DataPoint();
|
||||
goal = new DataPoint();
|
||||
}
|
||||
else
|
||||
{
|
||||
// local position/rotation for VR support
|
||||
ApplyPositionRotationScale(InterpolatePosition(start, goal, targetTransform.localPosition),
|
||||
InterpolateRotation(start, goal, targetTransform.localRotation),
|
||||
InterpolateScale(start, goal, targetTransform.localScale));
|
||||
}
|
||||
}
|
||||
|
||||
// moved or rotated or scaled since last time we checked it?
|
||||
bool HasEitherMovedRotatedScaled()
|
||||
{
|
||||
// Save last for next frame to compare only if change was detected, otherwise
|
||||
// slow moving objects might never sync because of C#'s float comparison tolerance.
|
||||
// See also: https://github.com/vis2k/Mirror/pull/428)
|
||||
bool changed = HasMoved || HasRotated || HasScaled;
|
||||
if (changed)
|
||||
{
|
||||
// local position/rotation for VR support
|
||||
if (syncPosition) lastPosition = targetTransform.localPosition;
|
||||
if (syncRotation) lastRotation = targetTransform.localRotation;
|
||||
if (syncScale) lastScale = targetTransform.localScale;
|
||||
}
|
||||
return changed;
|
||||
}
|
||||
|
||||
// local position/rotation for VR support
|
||||
// SqrMagnitude is faster than Distance per Unity docs
|
||||
// https://docs.unity3d.com/ScriptReference/Vector3-sqrMagnitude.html
|
||||
|
||||
bool HasMoved => syncPosition && Vector3.SqrMagnitude(lastPosition - targetTransform.localPosition) > localPositionSensitivity * localPositionSensitivity;
|
||||
bool HasRotated => syncRotation && Quaternion.Angle(lastRotation, targetTransform.localRotation) > localRotationSensitivity;
|
||||
bool HasScaled => syncScale && Vector3.SqrMagnitude(lastScale - targetTransform.localScale) > localScaleSensitivity * localScaleSensitivity;
|
||||
|
||||
// teleport / lag / stuck detection
|
||||
// - checking distance is not enough since there could be just a tiny fence between us and the goal
|
||||
// - checking time always works, this way we just teleport if we still didn't reach the goal after too much time has elapsed
|
||||
bool NeedsTeleport()
|
||||
{
|
||||
// calculate time between the two data points
|
||||
float startTime = start.isValid ? start.timeStamp : Time.time - Time.fixedDeltaTime;
|
||||
float goalTime = goal.isValid ? goal.timeStamp : Time.time;
|
||||
float difference = goalTime - startTime;
|
||||
float timeSinceGoalReceived = Time.time - goalTime;
|
||||
return timeSinceGoalReceived > difference * 5;
|
||||
}
|
||||
|
||||
// local authority client sends sync message to server for broadcasting
|
||||
[Command(channel = Channels.Unreliable)]
|
||||
void CmdClientToServerSync(Vector3 position, uint packedRotation, Vector3 scale)
|
||||
{
|
||||
// Ignore messages from client if not in client authority mode
|
||||
if (!clientAuthority)
|
||||
return;
|
||||
|
||||
// deserialize payload
|
||||
SetGoal(position, Compression.DecompressQuaternion(packedRotation), scale);
|
||||
|
||||
// server-only mode does no interpolation to save computations, but let's set the position directly
|
||||
if (isServer && !isClient)
|
||||
ApplyPositionRotationScale(goal.localPosition, goal.localRotation, goal.localScale);
|
||||
|
||||
RpcMove(position, packedRotation, scale);
|
||||
}
|
||||
|
||||
[ClientRpc(channel = Channels.Unreliable)]
|
||||
void RpcMove(Vector3 position, uint packedRotation, Vector3 scale)
|
||||
{
|
||||
if (hasAuthority && excludeOwnerUpdate) return;
|
||||
|
||||
if (!isServer)
|
||||
SetGoal(position, Compression.DecompressQuaternion(packedRotation), scale);
|
||||
}
|
||||
|
||||
// serialization is needed by OnSerialize and by manual sending from authority
|
||||
void SetGoal(Vector3 position, Quaternion rotation, Vector3 scale)
|
||||
{
|
||||
// put it into a data point immediately
|
||||
DataPoint temp = new DataPoint
|
||||
{
|
||||
// deserialize position
|
||||
localPosition = position,
|
||||
localRotation = rotation,
|
||||
localScale = scale,
|
||||
timeStamp = Time.time
|
||||
};
|
||||
|
||||
// movement speed: based on how far it moved since last time has to be calculated before 'start' is overwritten
|
||||
temp.movementSpeed = EstimateMovementSpeed(goal, temp, targetTransform, Time.fixedDeltaTime);
|
||||
|
||||
// reassign start wisely
|
||||
// first ever data point? then make something up for previous one so that we can start interpolation without waiting for next.
|
||||
if (start.timeStamp == 0)
|
||||
{
|
||||
start = new DataPoint
|
||||
{
|
||||
timeStamp = Time.time - Time.fixedDeltaTime,
|
||||
// local position/rotation for VR support
|
||||
localPosition = targetTransform.localPosition,
|
||||
localRotation = targetTransform.localRotation,
|
||||
localScale = targetTransform.localScale,
|
||||
movementSpeed = temp.movementSpeed
|
||||
};
|
||||
}
|
||||
// second or nth data point? then update previous
|
||||
// but: we start at where ever we are right now, so that it's perfectly smooth and we don't jump anywhere
|
||||
//
|
||||
// example if we are at 'x':
|
||||
//
|
||||
// A--x->B
|
||||
//
|
||||
// and then receive a new point C:
|
||||
//
|
||||
// A--x--B
|
||||
// |
|
||||
// |
|
||||
// C
|
||||
//
|
||||
// then we don't want to just jump to B and start interpolation:
|
||||
//
|
||||
// x
|
||||
// |
|
||||
// |
|
||||
// C
|
||||
//
|
||||
// we stay at 'x' and interpolate from there to C:
|
||||
//
|
||||
// x..B
|
||||
// \ .
|
||||
// \.
|
||||
// C
|
||||
//
|
||||
else
|
||||
{
|
||||
float oldDistance = Vector3.Distance(start.localPosition, goal.localPosition);
|
||||
float newDistance = Vector3.Distance(goal.localPosition, temp.localPosition);
|
||||
|
||||
start = goal;
|
||||
|
||||
// local position/rotation for VR support
|
||||
// teleport / lag / obstacle detection: only continue at current position if we aren't too far away
|
||||
// XC < AB + BC (see comments above)
|
||||
if (Vector3.Distance(targetTransform.localPosition, start.localPosition) < oldDistance + newDistance)
|
||||
{
|
||||
start.localPosition = targetTransform.localPosition;
|
||||
start.localRotation = targetTransform.localRotation;
|
||||
start.localScale = targetTransform.localScale;
|
||||
}
|
||||
}
|
||||
|
||||
// set new destination in any case. new data is best data.
|
||||
goal = temp;
|
||||
}
|
||||
|
||||
// try to estimate movement speed for a data point based on how far it moved since the previous one
|
||||
// - if this is the first time ever then we use our best guess:
|
||||
// - delta based on transform.localPosition
|
||||
// - elapsed based on send interval hoping that it roughly matches
|
||||
static float EstimateMovementSpeed(DataPoint from, DataPoint to, Transform transform, float sendInterval)
|
||||
{
|
||||
Vector3 delta = to.localPosition - (from.localPosition != transform.localPosition ? from.localPosition : transform.localPosition);
|
||||
float elapsed = from.isValid ? to.timeStamp - from.timeStamp : sendInterval;
|
||||
|
||||
// avoid NaN
|
||||
return elapsed > 0 ? delta.magnitude / elapsed : 0;
|
||||
}
|
||||
|
||||
// set position carefully depending on the target component
|
||||
void ApplyPositionRotationScale(Vector3 position, Quaternion rotation, Vector3 scale)
|
||||
{
|
||||
// local position/rotation for VR support
|
||||
if (syncPosition) targetTransform.localPosition = position;
|
||||
if (syncRotation) targetTransform.localRotation = rotation;
|
||||
if (syncScale) targetTransform.localScale = scale;
|
||||
}
|
||||
|
||||
// where are we in the timeline between start and goal? [0,1]
|
||||
Vector3 InterpolatePosition(DataPoint start, DataPoint goal, Vector3 currentPosition)
|
||||
{
|
||||
if (!interpolatePosition)
|
||||
return currentPosition;
|
||||
|
||||
if (start.movementSpeed != 0)
|
||||
{
|
||||
// Option 1: simply interpolate based on time, but stutter will happen, it's not that smooth.
|
||||
// This is especially noticeable if the camera automatically follows the player
|
||||
// - Tell SonarCloud this isn't really commented code but actual comments and to stfu about it
|
||||
// - float t = CurrentInterpolationFactor();
|
||||
// - return Vector3.Lerp(start.position, goal.position, t);
|
||||
|
||||
// Option 2: always += speed
|
||||
// speed is 0 if we just started after idle, so always use max for best results
|
||||
float speed = Mathf.Max(start.movementSpeed, goal.movementSpeed);
|
||||
return Vector3.MoveTowards(currentPosition, goal.localPosition, speed * Time.deltaTime);
|
||||
}
|
||||
|
||||
return currentPosition;
|
||||
}
|
||||
|
||||
Quaternion InterpolateRotation(DataPoint start, DataPoint goal, Quaternion defaultRotation)
|
||||
{
|
||||
if (!interpolateRotation)
|
||||
return defaultRotation;
|
||||
|
||||
if (start.localRotation != goal.localRotation)
|
||||
{
|
||||
float t = CurrentInterpolationFactor(start, goal);
|
||||
return Quaternion.Slerp(start.localRotation, goal.localRotation, t);
|
||||
}
|
||||
|
||||
return defaultRotation;
|
||||
}
|
||||
|
||||
Vector3 InterpolateScale(DataPoint start, DataPoint goal, Vector3 currentScale)
|
||||
{
|
||||
if (!interpolateScale)
|
||||
return currentScale;
|
||||
|
||||
if (start.localScale != goal.localScale)
|
||||
{
|
||||
float t = CurrentInterpolationFactor(start, goal);
|
||||
return Vector3.Lerp(start.localScale, goal.localScale, t);
|
||||
}
|
||||
|
||||
return currentScale;
|
||||
}
|
||||
|
||||
static float CurrentInterpolationFactor(DataPoint start, DataPoint goal)
|
||||
{
|
||||
if (start.isValid)
|
||||
{
|
||||
float difference = goal.timeStamp - start.timeStamp;
|
||||
|
||||
// the moment we get 'goal', 'start' is supposed to start, so elapsed time is based on:
|
||||
float elapsed = Time.time - goal.timeStamp;
|
||||
|
||||
// avoid NaN
|
||||
return difference > 0 ? elapsed / difference : 1;
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
|
||||
#region Server Teleport (force move player)
|
||||
|
||||
/// <summary>
|
||||
/// This method will override this GameObject's current Transform.localPosition to the specified Vector3 and update all clients.
|
||||
/// <para>NOTE: position must be in LOCAL space if the transform has a parent</para>
|
||||
/// </summary>
|
||||
/// <param name="localPosition">Where to teleport this GameObject</param>
|
||||
[Server]
|
||||
public void ServerTeleport(Vector3 localPosition)
|
||||
{
|
||||
Quaternion localRotation = targetTransform.localRotation;
|
||||
ServerTeleport(localPosition, localRotation);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// This method will override this GameObject's current Transform.localPosition and Transform.localRotation
|
||||
/// to the specified Vector3 and Quaternion and update all clients.
|
||||
/// <para>NOTE: localPosition must be in LOCAL space if the transform has a parent</para>
|
||||
/// <para>NOTE: localRotation must be in LOCAL space if the transform has a parent</para>
|
||||
/// </summary>
|
||||
/// <param name="localPosition">Where to teleport this GameObject</param>
|
||||
/// <param name="localRotation">Which rotation to set this GameObject</param>
|
||||
[Server]
|
||||
public void ServerTeleport(Vector3 localPosition, Quaternion localRotation)
|
||||
{
|
||||
// To prevent applying the position updates received from client (if they have ClientAuth) while being teleported.
|
||||
// clientAuthorityBeforeTeleport defaults to false when not teleporting, if it is true then it means that teleport
|
||||
// was previously called but not finished therefore we should keep it as true so that 2nd teleport call doesn't clear authority
|
||||
clientAuthorityBeforeTeleport = clientAuthority || clientAuthorityBeforeTeleport;
|
||||
clientAuthority = false;
|
||||
|
||||
DoTeleport(localPosition, localRotation);
|
||||
|
||||
// tell all clients about new values
|
||||
RpcTeleport(localPosition, Compression.CompressQuaternion(localRotation), clientAuthorityBeforeTeleport);
|
||||
}
|
||||
|
||||
void DoTeleport(Vector3 newLocalPosition, Quaternion newLocalRotation)
|
||||
{
|
||||
targetTransform.localPosition = newLocalPosition;
|
||||
targetTransform.localRotation = newLocalRotation;
|
||||
|
||||
// Since we are overriding the position we don't need a goal and start.
|
||||
// Reset them to null for fresh start
|
||||
goal = new DataPoint();
|
||||
start = new DataPoint();
|
||||
lastPosition = newLocalPosition;
|
||||
lastRotation = newLocalRotation;
|
||||
}
|
||||
|
||||
[ClientRpc(channel = Channels.Unreliable)]
|
||||
void RpcTeleport(Vector3 newPosition, uint newPackedRotation, bool isClientAuthority)
|
||||
{
|
||||
DoTeleport(newPosition, Compression.DecompressQuaternion(newPackedRotation));
|
||||
|
||||
// only send finished if is owner and is ClientAuthority on server
|
||||
if (hasAuthority && isClientAuthority)
|
||||
CmdTeleportFinished();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// This RPC will be invoked on server after client finishes overriding the position.
|
||||
/// </summary>
|
||||
/// <param name="initialAuthority"></param>
|
||||
[Command(channel = Channels.Unreliable)]
|
||||
void CmdTeleportFinished()
|
||||
{
|
||||
if (clientAuthorityBeforeTeleport)
|
||||
{
|
||||
clientAuthority = true;
|
||||
|
||||
// reset value so doesn't effect future calls, see note in ServerTeleport
|
||||
clientAuthorityBeforeTeleport = false;
|
||||
}
|
||||
else
|
||||
{
|
||||
Debug.LogWarning("Client called TeleportFinished when clientAuthority was false on server", this);
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Debug Gizmos
|
||||
|
||||
// draw the data points for easier debugging
|
||||
void OnDrawGizmos()
|
||||
{
|
||||
// draw start and goal points and a line between them
|
||||
if (start.localPosition != goal.localPosition)
|
||||
{
|
||||
DrawDataPointGizmo(start, Color.yellow);
|
||||
DrawDataPointGizmo(goal, Color.green);
|
||||
DrawLineBetweenDataPoints(start, goal, Color.cyan);
|
||||
}
|
||||
}
|
||||
|
||||
static void DrawDataPointGizmo(DataPoint data, Color color)
|
||||
{
|
||||
// use a little offset because transform.localPosition might be in the ground in many cases
|
||||
Vector3 offset = Vector3.up * 0.01f;
|
||||
|
||||
// draw position
|
||||
Gizmos.color = color;
|
||||
Gizmos.DrawSphere(data.localPosition + offset, 0.5f);
|
||||
|
||||
// draw forward and up like unity move tool
|
||||
Gizmos.color = Color.blue;
|
||||
Gizmos.DrawRay(data.localPosition + offset, data.localRotation * Vector3.forward);
|
||||
Gizmos.color = Color.green;
|
||||
Gizmos.DrawRay(data.localPosition + offset, data.localRotation * Vector3.up);
|
||||
}
|
||||
|
||||
static void DrawLineBetweenDataPoints(DataPoint data1, DataPoint data2, Color color)
|
||||
{
|
||||
Gizmos.color = color;
|
||||
Gizmos.DrawLine(data1.localPosition, data2.localPosition);
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user