#if !FISHNET_STABLE_REPLICATESTATES using System; using FishNet.Component.Prediction; using FishNet.Connection; using FishNet.Object; using FishNet.Object.Prediction; using FishNet.Transporting; using FishNet.Utility.Template; using UnityEngine; namespace FishNet.Demo.Prediction.CharacterControllers { public class CharacterControllerPrediction : TickNetworkBehaviour { #region Types. /// /// One-time inputs accumulated over frames between ticks. /// public struct OneTimeInput { /// /// True to jump. /// public bool Jump; /// /// Unset inputs. /// public void ResetState() { Jump = false; } } public struct ReplicateData : IReplicateData { public ReplicateData(Vector2 input, bool run, OneTimeInput oneTimeInputs) { OneTimeInputs = oneTimeInputs; Input = input; Run = run; _tick = 0; } /// /// True if Jump input was pressed for the tick. /// public OneTimeInput OneTimeInputs; /// /// Current movement directions held. /// public Vector2 Input; /// /// True if run is held. /// public readonly bool Run; /// /// Tick is set at runtime. There is no need to manually assign this value. /// private uint _tick; public void Dispose() { OneTimeInputs.ResetState(); } public uint GetTick() => _tick; public void SetTick(uint value) => _tick = value; } public struct ReconcileData : IReconcileData { public ReconcileData(Vector3 position, float verticalVelocity, float stamina, MovingPlatform currentPlatform) { Position = position; VerticalVelocity = verticalVelocity; Stamina = stamina; CurrentPlatform = currentPlatform; _tick = 0; } /// /// Position of the character. /// public Vector3 Position; /// /// Current vertical velocity. /// /// Used to simulate jumps and falls. public float VerticalVelocity; /// /// Amount of stamina remaining to run or jump. /// public float Stamina; public MovingPlatform CurrentPlatform; /// /// Tick is set at runtime. There is no need to manually assign this value. /// private uint _tick; public void Dispose() { } public uint GetTick() => _tick; public void SetTick(uint value) => _tick = value; } #endregion /// /// Invokes whenever NetworkStart is called for owner. /// public static event Action OnOwner; /// /// Current stamina remaining. /// public float Stamina { get; private set; } /// /// Amount of force for jumping. /// [Tooltip("Amount of force for jumping.")] [SerializeField] private float _jumpForce = 30f; /// /// How quickly to move. /// [Tooltip("How quickly to move.")] [SerializeField] private float _moveRate = 4f; /// /// Current vertical velocity. /// private float _verticalVelocity; /// /// One-time inputs accumulated over frames between ticks. /// private OneTimeInput _oneTimeInputs = new(); /// /// Last data which was supplied during replicate outside of reconcile. /// private ReplicateData _lastTickedReplicateData = default; /// /// Current platform the player is on. /// private MovingPlatform _currentPlatform; /// /// Reference to the CharacterController component. /// private CharacterController _characterController; /// /// NetworkTrigger on this character; used to detact platforms. /// private NetworkTrigger _characterTrigger; /// /// maximum amount of stamina allowed. /// public const float Maximum_Stamina = 50f; private void Awake() { _characterController = GetComponent(); _characterTrigger = GetComponentInChildren(); _characterTrigger.OnEnter += CharacterTrigger_OnEnter; _characterTrigger.OnExit += CharacterTrigger_OnExit; //We only need the OnTick callback for non-physics. base.SetTickCallbacks(TickCallback.Tick); } public override void OnOwnershipClient(NetworkConnection prevOwner) { if (base.IsOwner) OnOwner?.Invoke(this); } private void Update() { SetOneTimeInputs(); } /// /// Checks setting inputs which are one-time(not held). /// private void SetOneTimeInputs() { if (!base.IsOwner) return; /* Check to jump. */ if (Input.GetKeyDown(KeyCode.Space)) _oneTimeInputs.Jump = true; } protected override void TimeManager_OnTick() { PerformReplicate(BuildMoveData()); CreateReconcile(); } /// /// Returns replicate data to send as the controller. /// private ReplicateData BuildMoveData() { /* Only the controller needs to build move data. * This could be the server if the server if no owner, for example * such as AI, or the owner of the object. */ if (!base.IsOwner) return default; float horizontal = Input.GetAxisRaw("Horizontal"); float vertical = Input.GetAxisRaw("Vertical"); //Run when left shift is held. bool run = Input.GetKey(KeyCode.LeftShift); ReplicateData md = new(new(horizontal, vertical), run, _oneTimeInputs); //Reset one tine inputs since they've been processed for the tick. _oneTimeInputs.ResetState(); return md; } /// /// Creates a reconcile that is sent to clients. /// public override void CreateReconcile() { /* Both the server and client should create reconcile data. * The client will use their copy as a fallback if they do not * get data from the server, such as a dropped packet. * * The client will not reconcile unless it receives at least one * reconcile packet from the server for the tick. */ /* You do not have to reconcile every tick if you wish to * save bandwidth/perf, or simply feel as though it's not needed * for your game type. * * Even when not reconciling every tick it's still recommended * to build the reconcile as client; this cost is very little.*/ /* This is an example of only sending a reconcile occasionally * if the server. Simply uncomment the if statement below to * test this behavior. */ // if (base.IsServerStarted) // { // //Exit early if 10 ticks have not passed. // if (base.TimeManager.LocalTick % 10 != 0) return; // } //Build the data using current information and call the reconcile method. ReconcileData rd = new(transform.localPosition, _verticalVelocity, Stamina, _currentPlatform); PerformReconcile(rd); } [Replicate] private void PerformReplicate(ReplicateData rd, ReplicateState state = ReplicateState.Invalid, Channel channel = Channel.Unreliable) { //Always use the tickDelta as your delta when performing actions inside replicate. float delta = (float)base.TimeManager.TickDelta; bool useDefaultForces = false; /* When client only run some checks to * further predict the clients future movement. * This can keep the object more inlined with real-time by * guessing what the clients input might be before we * actually receive it. * * Doing this does risk a chance of graphical jitter in the * scenario a de-synchronization occurs, but if only predicting * a couple ticks the chances are low. */ //See https://fish-networking.gitbook.io/docs/manual/guides/prediction/version-2/creating-code/predicting-states if (!base.IsServerStarted && !base.IsOwner) { /* If ticked then set last ticked value. * Ticked means the replicate is being run from the tick cycle, more * specifically NOT from a replay/reconcile. */ if (state.ContainsTicked()) { /* Dispose of old should it have anything that needs to be cleaned up. * If you are only using value types in your data you do not need to call Dispose. * You must implement dispose manually to cache any non-value types, if you wish. */ _lastTickedReplicateData.Dispose(); //Set new. _lastTickedReplicateData = rd; } /* In the future means there is no way the data can be known to this client * yet. For example, the client is running this script locally and due to * how networking works, they have not yet received the latest information from * the server. * * If in the future then we are only going to predict up to * a certain amount of ticks in the future. This is us assuming that the * server (or client which owns this in this case) is going to use the * same input for at least X number of ticks. You can predict none, or as many * as you like, but the more inputs you predict the higher likeliness of guessing * wrong. If you do however predict wrong often smoothing will cover up the mistake. */ else if (state.IsFuture()) { /* Predict up to 1 tick more. */ if (rd.GetTick() - _lastTickedReplicateData.GetTick() > 1) { useDefaultForces = true; } else { /* If here we are predicting the future. */ /* You likely do not need to dispose rd here since it would be default * when state is 'not created'. We are simply doing it for good practice, should your ReplicateData * contain any garbage collection. */ rd.Dispose(); rd = _lastTickedReplicateData; /* There are some fields you might not want to predict, for example * jump. The odds of a client pressing jump two ticks in a row is unlikely. * The stamina check below would likely prevent such a scenario. * * We're going to unset jump for this reason. */ rd.OneTimeInputs.Jump = false; /* Be aware that future predicting is not a one-size fits all * feature. How much you predict into the future, if at all, depends * on your game mechanics and your desired outcome. */ } } } Vector3 forces; if (useDefaultForces) { /* Character controllers are a bit problematic with colliders. * If you were to pass Vector3.zero into the move then there's * a chance other colliders will clip through the characterController. * When this is combined with reconciles, it's practically gauranteed * this will happen. * * Because of this issue, if we are 'using default forces' we will apply a * very insignificant amount of force which makes the characterController * update properly. */ forces = new(0f, -1f, 0f); } //Calculate forces. else { //Stamina regained over every second. const float regainedStamina = 25f; //Add stamina with every tick. ModifyStamina(regainedStamina * delta); //Add gravity. Extra gravity is added for snappier jumps. _verticalVelocity += (Physics.gravity.y * delta * 3f); //Cap gravity to -20f so the player doesn't fall too fast. if (_verticalVelocity < -40f) _verticalVelocity = -40f; //Normalize direction so the player does not move faster at angles. rd.Input = rd.Input.normalized; /* Typically speaking any modification which can affect your CSP (prediction) should occur * inside replicate. This is why we add/remove stamina, and move within replicate. */ //Default run multiplier. float runMultiplier; //Stamina required to run over a second. const float runStamina = 50f; if (rd.Run && TryRemoveStamina(runStamina * delta)) runMultiplier = 1.5f; else runMultiplier = 1f; //Stamina required to jump. const byte jumpStamina = 30; /* For consistent jumps set to jump force when jumping, rather * than add force onto current gravity. */ if (rd.OneTimeInputs.Jump && TryRemoveStamina(jumpStamina)) _verticalVelocity = _jumpForce; forces = new Vector3(rd.Input.x, 0f, rd.Input.y) * (_moveRate * runMultiplier); //Add vertical velocity to forces. forces.y = _verticalVelocity; } _characterController.Move(forces * delta); } [Reconcile] private void PerformReconcile(ReconcileData rd, Channel channel = Channel.Unreliable) { /* Simply set current values to as they are * in the reconcile data. */ _verticalVelocity = rd.VerticalVelocity; Stamina = rd.Stamina; /* Even though the platform is traced for in replicate we must also * pass the current platform into the reconcile, and set our local value * to whatever is provided in the reconcile. * * This is done because we use local position to reset the character * and if the clients currentPlatform differs from what the server * had when reconciling the world position would be significantly * different due to the parent not aligning. */ _currentPlatform = rd.CurrentPlatform; //Set transform parent after assigning current. SetParent(); /* Update position AFTER setting the parent, otherwise * you would face a potentially huge positional de-sync * as mentioned above. */ transform.localPosition = rd.Position; } /// /// Called when the trigger on this object enters another collider. /// private void CharacterTrigger_OnEnter(Collider c) { //We only care about moving platforms. if (!c.TryGetComponent(out MovingPlatform mp)) return; _currentPlatform = mp; SetParent(); } /// /// Called when the trigger on this object exits another collider. /// private void CharacterTrigger_OnExit(Collider c) { if (!c.TryGetComponent(out MovingPlatform mp)) return; //Only check if already attached to a platform. if (_currentPlatform == null) return; //Not the current platform. if (_currentPlatform != mp) return; //Is the current platform, unset. _currentPlatform = null; SetParent(); } /// /// Updates parent state based on current platforms value. /// private void SetParent() { if (_currentPlatform != null) transform.SetParent(_currentPlatform.transform); else transform.SetParent(null); } /// /// Modifies stamina by adding or removing stamina. /// private void ModifyStamina(float value) { float next = Stamina + value; Stamina = Mathf.Clamp(next, 0f, Maximum_Stamina); } /// /// Removes stamina if enough stamina is available. /// /// >True if stamina was available and removed. private bool TryRemoveStamina(float value) { if (Stamina < value) return false; Stamina -= value; return true; } } } #endif