mirror of
				https://github.com/DerTyp7/defrain-shooter-unity.git
				synced 2025-10-31 05:27:07 +01:00 
			
		
		
		
	
		
			
				
	
	
		
			530 lines
		
	
	
		
			22 KiB
		
	
	
	
		
			C#
		
	
	
	
	
	
			
		
		
	
	
			530 lines
		
	
	
		
			22 KiB
		
	
	
	
		
			C#
		
	
	
	
	
	
| // 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
 | |
|     }
 | |
| }
 | 
