diff --git a/docs/learn/getting-started-boss-room.md b/docs/learn/getting-started-boss-room.md index 296b34aec..cc5995c28 100644 --- a/docs/learn/getting-started-boss-room.md +++ b/docs/learn/getting-started-boss-room.md @@ -29,7 +29,7 @@ Using Windows' built-in extracting tool may generate a "Error 0x80010135: Path t :::important Compatibility - Boss Room supports all major Unity platforms. To use the WebGL platform a custom WebGL transport based on web sockets is needed. -- Boss Room is compatible with Unity 2020.3.0f1. +- Boss Room is compatible with Unity 2020.3 LTS and later. - Make sure to include standalone support for Windows/Mac in your installation. ::: diff --git a/docs/learn/rpcnetvarexamples.md b/docs/learn/rpcnetvarexamples.md index ea269a7ab..72ce2debc 100644 --- a/docs/learn/rpcnetvarexamples.md +++ b/docs/learn/rpcnetvarexamples.md @@ -10,401 +10,8 @@ See the [RPC vs NetworkVariable](rpcvnetvar.md) tutorial for more information. ## RPCs for movement Boss Room uses RPCs to send movement inputs. - -```csharp -using MLAPI; -using System; -using System.Collections.Generic; -using MLAPI.Spawning; -using UnityEngine; -using UnityEngine.Assertions; -using UnityEngine.EventSystems; - -namespace BossRoom.Client -{ - /// - /// Captures inputs for a character on a client and sends them to the server. - /// - [RequireComponent(typeof(NetworkCharacterState))] - public class ClientInputSender : NetworkBehaviour - { - private const float k_MouseInputRaycastDistance = 100f; - - private const float k_MoveSendRateSeconds = 0.5f; - - private float m_LastSentMove; - - // Cache raycast hit array so that we can use non alloc raycasts - private readonly RaycastHit[] k_CachedHit = new RaycastHit[4]; - - // This is basically a constant but layer masks cannot be created in the constructor, that's why it's assigned int Awake. - private LayerMask k_GroundLayerMask; - private LayerMask k_ActionLayerMask; - - private NetworkCharacterState m_NetworkCharacter; - - /// - /// This describes how a skill was requested. Skills requested via mouse click will do raycasts to determine their target; skills requested - /// in other matters will use the stateful target stored in NetworkCharacterState. - /// - public enum SkillTriggerStyle - { - None, //no skill was triggered. - MouseClick, //skill was triggered via mouse-click implying you should do a raycast from the mouse position to find a target. - Keyboard, //skill was triggered via a Keyboard press, implying target should be taken from the active target. - KeyboardRelease, //represents a released key. - UI, //skill was triggered from the UI, and similar to Keyboard, target should be inferred from the active target. - } - - /// - /// This struct essentially relays the call params of RequestAction to FixedUpdate. Recall that we may need to do raycasts - /// as part of doing the action, and raycasts done outside of FixedUpdate can give inconsistent results (otherwise we would - /// just expose PerformAction as a public method, and let it be called in whatever scoped it liked. - /// - /// - /// Reference: https://answers.unity.com/questions/1141633/why-does-fixedupdate-work-when-update-doesnt.html - /// - private struct ActionRequest - { - public SkillTriggerStyle TriggerStyle; - public ActionType RequestedAction; - public ulong TargetId; - } - - /// - /// List of ActionRequests that have been received since the last FixedUpdate ran. This is a static array, to avoid allocs, and - /// because we don't really want to let this list grow indefinitely. - /// - private readonly ActionRequest[] m_ActionRequests = new ActionRequest[5]; - - /// - /// Number of ActionRequests that have been queued since the last FixedUpdate. - /// - private int m_ActionRequestCount; - - private BaseActionInput m_CurrentSkillInput = null; - private bool m_MoveRequest = false; - - - Camera m_MainCamera; - - public event Action OnClientClick; - - /// - /// Convenience getter that returns our CharacterData - /// - CharacterClass CharacterData => GameDataSource.Instance.CharacterDataByType[m_NetworkCharacter.CharacterType]; - - public override void NetworkStart() - { - // TODO Don't use NetworkBehaviour for just NetworkStart [GOMPS-81] - if (!IsClient || !IsOwner) - { - enabled = false; - // dont need to do anything else if not the owner - return; - } - - k_GroundLayerMask = LayerMask.GetMask(new[] { "Ground" }); - k_ActionLayerMask = LayerMask.GetMask(new[] { "PCs", "NPCs", "Ground" }); - - // find the hero action UI bar - GameObject actionUIobj = GameObject.FindGameObjectWithTag("HeroActionBar"); - actionUIobj.GetComponent().RegisterInputSender(this); - - // find the emote bar to track its buttons - GameObject emoteUIobj = GameObject.FindGameObjectWithTag("HeroEmoteBar"); - emoteUIobj.GetComponent().RegisterInputSender(this); - // once connected to the emote bar, hide it - emoteUIobj.SetActive(false); - } - - void Awake() - { - m_NetworkCharacter = GetComponent(); - m_MainCamera = Camera.main; - } - - public void FinishSkill() - { - m_CurrentSkillInput = null; - } - - void FixedUpdate() - { - //play all ActionRequests, in FIFO order. - for (int i = 0; i < m_ActionRequestCount; ++i) - { - if( m_CurrentSkillInput != null ) - { - //actions requested while input is active are discarded, except for "Release" requests, which go through. - if (m_ActionRequests[i].TriggerStyle == SkillTriggerStyle.KeyboardRelease ) - { - m_CurrentSkillInput.OnReleaseKey(); - } - } - else - { - var actionData = GameDataSource.Instance.ActionDataByType[m_ActionRequests[i].RequestedAction]; - if (actionData.ActionInput != null) - { - var skillPlayer = Instantiate(actionData.ActionInput); - skillPlayer.Initiate(m_NetworkCharacter, actionData.ActionTypeEnum, FinishSkill); - m_CurrentSkillInput = skillPlayer; - } - else - { - PerformSkill(actionData.ActionTypeEnum, m_ActionRequests[i].TriggerStyle, m_ActionRequests[i].TargetId); - } - } - } - - m_ActionRequestCount = 0; - - if( m_MoveRequest ) - { - m_MoveRequest = false; - if ( (Time.time - m_LastSentMove) > k_MoveSendRateSeconds) - { - m_LastSentMove = Time.time; - var ray = m_MainCamera.ScreenPointToRay(Input.mousePosition); - if (Physics.RaycastNonAlloc(ray, k_CachedHit, k_MouseInputRaycastDistance, k_GroundLayerMask) > 0) - { - // The MLAPI_INTERNAL channel is a reliable sequenced channel. Inputs should always arrive and be in order that's why this channel is used. - m_NetworkCharacter.SendCharacterInputServerRpc(k_CachedHit[0].point); - //Send our client only click request - OnClientClick?.Invoke(k_CachedHit[0].point); - } - } - } - } - - /// - /// Perform a skill in response to some input trigger. This is the common method to which all input-driven skill plays funnel. - /// - /// The action you want to play. Note that "Skill1" may be overriden contextually depending on the target. - /// What sort of input triggered this skill? - /// (optional) Pass in a specific networkID to target for this action - private void PerformSkill(ActionType actionType, SkillTriggerStyle triggerStyle, ulong targetId = 0) - { - Transform hitTransform = null; - - if (targetId != 0) - { - // if a targetId is given, try to find the object - NetworkObject targetNetObj; - if (NetworkSpawnManager.SpawnedObjects.TryGetValue(targetId, out targetNetObj)) - { - hitTransform = targetNetObj.transform; - } - } - else - { - // otherwise try to find an object under the input position - int numHits = 0; - if (triggerStyle == SkillTriggerStyle.MouseClick) - { - var ray = m_MainCamera.ScreenPointToRay(Input.mousePosition); - numHits = Physics.RaycastNonAlloc(ray, k_CachedHit, k_MouseInputRaycastDistance, k_ActionLayerMask); - } - - int networkedHitIndex = -1; - for (int i = 0; i < numHits; i++) - { - if (k_CachedHit[i].transform.GetComponent()) - { - networkedHitIndex = i; - break; - } - } - - hitTransform = networkedHitIndex >= 0 ? k_CachedHit[networkedHitIndex].transform : null; - } - - if (GetActionRequestForTarget(hitTransform, actionType, triggerStyle, out ActionRequestData playerAction)) - { - //Don't trigger our move logic for another 500ms. This protects us from moving just because we clicked on them to target them. - m_LastSentMove = Time.time; - m_NetworkCharacter.RecvDoActionServerRPC(playerAction); - } - else if(actionType != ActionType.GeneralTarget ) - { - // clicked on nothing... perform a "miss" attack on the spot they clicked on - var data = new ActionRequestData(); - PopulateSkillRequest(k_CachedHit[0].point, actionType, ref data); - m_NetworkCharacter.RecvDoActionServerRPC(data); - } - } - - /// - /// When you right-click on something you will want to do contextually different things. For example you might attack an enemy, - /// but revive a friend. You might also decide to do nothing (e.g. right-clicking on a friend who hasn't FAINTED). - /// - /// The Transform of the entity we clicked on, or null if none. - /// The Action to build for - /// How did this skill play get triggered? Mouse, Keyboard, UI etc. - /// Out parameter that will be filled with the resulting action, if any. - /// true if we should play an action, false otherwise. - private bool GetActionRequestForTarget(Transform hit, ActionType actionType, SkillTriggerStyle triggerStyle, out ActionRequestData resultData) - { - resultData = new ActionRequestData(); - - var targetNetObj = hit != null ? hit.GetComponent() : null; - - //if we can't get our target from the submitted hit transform, get it from our stateful target in our NetworkCharacterState. - if (!targetNetObj && actionType != ActionType.GeneralTarget) - { - ulong targetId = m_NetworkCharacter.TargetId.Value; - NetworkSpawnManager.SpawnedObjects.TryGetValue(targetId, out targetNetObj); - } - - //sanity check that this is indeed a valid target. - if(targetNetObj==null || !ActionUtils.IsValidTarget(targetNetObj.NetworkObjectId)) - { - return false; - } - - var targetNetState = targetNetObj.GetComponent(); - if (targetNetState != null) - { - //Skill1 may be contextually overridden if it was generated from a mouse-click. - if (actionType == CharacterData.Skill1 && triggerStyle == SkillTriggerStyle.MouseClick) - { - if (!targetNetState.IsNpc && targetNetState.NetworkLifeState.Value == LifeState.Fainted) - { - //right-clicked on a downed ally--change the skill play to Revive. - actionType = ActionType.GeneralRevive; - } - } - } - - // record our target in case this action uses that info (non-targeted attacks will ignore this) - resultData.ActionTypeEnum = actionType; - resultData.TargetIds = new ulong[] { targetNetObj.NetworkObjectId }; - PopulateSkillRequest(targetNetObj.transform.position, actionType, ref resultData); - return true; - } - - /// - /// Populates the ActionRequestData with additional information. The TargetIds of the action should already be set before calling this. - /// - /// The point in world space where the click ray hit the target. - /// The action to perform (will be stamped on the resultData) - /// The ActionRequestData to be filled out with additional information. - private void PopulateSkillRequest(Vector3 hitPoint, ActionType action, ref ActionRequestData resultData) - { - resultData.ActionTypeEnum = action; - var actionInfo = GameDataSource.Instance.ActionDataByType[action]; - - //most skill types should implicitly close distance. The ones that don't are explicitly set to false in the following switch. - resultData.ShouldClose = true; - - switch (actionInfo.Logic) - { - //for projectile logic, infer the direction from the click position. - case ActionLogic.LaunchProjectile: - Vector3 offset = hitPoint - transform.position; - offset.y = 0; - resultData.Direction = offset.normalized; - resultData.ShouldClose = false; //why? Because you could be lining up a shot, hoping to hit other people between you and your target. Moving you would be quite invasive. - return; - case ActionLogic.Target: - resultData.ShouldClose = false; - return; - case ActionLogic.Emote: - resultData.CancelMovement = true; - return; - case ActionLogic.RangedFXTargeted: - if (resultData.TargetIds == null) { resultData.Position = hitPoint; } - return; - } - } - - /// - /// Request an action be performed. This will occur on the next FixedUpdate. - /// - /// the action you'd like to perform. - /// What input style triggered this action. - public void RequestAction(ActionType action, SkillTriggerStyle triggerStyle, ulong targetId = 0) - { - // do not populate an action request unless said action is valid - if (action == ActionType.None) - { - return; - } - - Assert.IsTrue(GameDataSource.Instance.ActionDataByType.ContainsKey(action), - $"Action {action} must be part of ActionData dictionary!"); - - if( m_ActionRequestCount < m_ActionRequests.Length ) - { - m_ActionRequests[m_ActionRequestCount].RequestedAction = action; - m_ActionRequests[m_ActionRequestCount].TriggerStyle = triggerStyle; - m_ActionRequests[m_ActionRequestCount].TargetId = targetId; - m_ActionRequestCount++; - } - } - - void Update() - { - if (Input.GetKeyDown(KeyCode.Alpha1)) - { - RequestAction(CharacterData.Skill2, SkillTriggerStyle.Keyboard); - } - else if (Input.GetKeyUp(KeyCode.Alpha1)) - { - RequestAction(CharacterData.Skill2, SkillTriggerStyle.KeyboardRelease); - } - if (Input.GetKeyDown(KeyCode.Alpha2)) - { - RequestAction(CharacterData.Skill3, SkillTriggerStyle.Keyboard); - } - else if (Input.GetKeyUp(KeyCode.Alpha2)) - { - RequestAction(CharacterData.Skill3, SkillTriggerStyle.KeyboardRelease); - } - - if (Input.GetKeyDown(KeyCode.Alpha4)) - { - RequestAction(ActionType.Emote1, SkillTriggerStyle.Keyboard); - } - if (Input.GetKeyDown(KeyCode.Alpha5)) - { - RequestAction(ActionType.Emote2, SkillTriggerStyle.Keyboard); - } - if (Input.GetKeyDown(KeyCode.Alpha6)) - { - RequestAction(ActionType.Emote3, SkillTriggerStyle.Keyboard); - } - if (Input.GetKeyDown(KeyCode.Alpha7)) - { - RequestAction(ActionType.Emote4, SkillTriggerStyle.Keyboard); - } - - if ( !EventSystem.current.IsPointerOverGameObject() && m_CurrentSkillInput == null) - { - //IsPointerOverGameObject() is a simple way to determine if the mouse is over a UI element. If it is, we don't perform mouse input logic, - //to model the button "blocking" mouse clicks from falling through and interacting with the world. - - if (Input.GetMouseButtonDown(1)) - { - RequestAction(CharacterData.Skill1, SkillTriggerStyle.MouseClick); - } - - if (Input.GetMouseButtonDown(0)) - { - RequestAction(ActionType.GeneralTarget, SkillTriggerStyle.MouseClick); - } - else if (Input.GetMouseButton(0)) - { - m_MoveRequest = true; - } - } - } - } -} ``` @@ -415,11 +22,8 @@ We want the full history of inputs sent, not just the latest value. There is no Sending from server to client `RecvPerformHitReactionClient` - -```csharp -InvokeClientRpcOnEveryone(RecvPerformHitReactionClient); ``` For example, the Boss Room project "ouch" action `RPC` mentioned for `NetworkCharacterState` is interesting for optimization purposes. You would normally want to have only one `RPC` for an action and let the client decide who should play the associated animation. Due to "ouch" being a long running action over multiple frames, you do not know yet when sending the initial `RPC` which characters will be affected by that action. You want this to be dynamic as the boss is hitting targets. As a result, multiple `RPC`s will be sent for each hit character. @@ -427,310 +31,32 @@ For example, the Boss Room project "ouch" action `RPC` mentioned for `NetworkCha ## Arrow's GameObject vs Fireball's VFX The archer's arrows uses a standalone `GameObject` that is replicated over time. Since this object's movements are slow moving, we made the choice to use state to replicate this ability's status, in case a client connected while the arrow was flying. - -```csharp -using MLAPI; -using System.Collections.Generic; -using System.IO; -using MLAPI.Spawning; -using UnityEngine; - -namespace BossRoom.Server -{ - - public class ServerProjectileLogic : MLAPI.NetworkBehaviour - { - private bool m_Started = false; - - [SerializeField] - private NetworkProjectileState m_NetState; - - [SerializeField] - private SphereCollider m_OurCollider; - - /// - /// The character that created us. Can be 0 to signal that we were created generically by the server. - /// - private ulong m_SpawnerId; - - /// - /// The data for our projectile. Indicates speed, damage, etc. - /// - private ActionDescription.ProjectileInfo m_ProjectileInfo; - - private const int k_MaxCollisions = 4; - private const float k_WallLingerSec = 2f; //time in seconds that arrows linger after hitting a target. - private const float k_EnemyLingerSec = 0.2f; //time after hitting an enemy that we persist. - private Collider[] m_CollisionCache = new Collider[k_MaxCollisions]; - - /// - /// Time when we should destroy this arrow, in Time.time seconds. - /// - private float m_DestroyAtSec; - - private int m_CollisionMask; //mask containing everything we test for while moving - private int m_BlockerMask; //physics mask for things that block the arrow's flight. - private int m_NPCLayer; - - /// - /// List of everyone we've hit and dealt damage to. - /// - /// - /// Note that it's possible for entries in this list to become null if they're Destroyed post-impact. - /// But that's fine by us! We use m_HitTargets.Count to tell us how many total enemies we've hit, - /// so those nulls still count as hits. - /// - private List m_HitTargets = new List(); - - /// - /// Are we done moving? - /// - private bool m_IsDead; - - /// - /// Set everything up based on provided projectile information. - /// (Note that this is called before NetworkStart(), so don't try to do any network stuff here.) - /// - public void Initialize(ulong creatorsNetworkObjectId, in ActionDescription.ProjectileInfo projectileInfo) - { - m_SpawnerId = creatorsNetworkObjectId; - m_ProjectileInfo = projectileInfo; - } - - public override void NetworkStart(Stream stream) - { - if (!IsServer) - { - enabled = false; - return; - } - - m_Started = true; - - m_DestroyAtSec = Time.fixedTime + (m_ProjectileInfo.Range / m_ProjectileInfo.Speed_m_s); - - m_CollisionMask = LayerMask.GetMask(new[] { "NPCs", "Default", "Ground" }); - m_BlockerMask = LayerMask.GetMask(new[] { "Default", "Ground" }); - m_NPCLayer = LayerMask.NameToLayer("NPCs"); - - RefreshNetworkState(); - } - - private void FixedUpdate() - { - if (!m_Started) { return; } //don't do anything before NetworkStart has run. - - Vector3 displacement = transform.forward * (m_ProjectileInfo.Speed_m_s * Time.fixedDeltaTime); - transform.position += displacement; - - if (m_DestroyAtSec < Time.fixedTime) - { - // Time to go away. - Destroy(gameObject); - } - - if (!m_IsDead) - { - DetectCollisions(); - } - - RefreshNetworkState(); - } - - private void RefreshNetworkState() - { - m_NetState.NetworkPosition.Value = transform.position; - m_NetState.NetworkRotationY.Value = transform.eulerAngles.y; - m_NetState.NetworkMovementSpeed.Value = m_ProjectileInfo.Speed_m_s; - } - - private void DetectCollisions() - { - Vector3 position = transform.localToWorldMatrix.MultiplyPoint(m_OurCollider.center); - int numCollisions = Physics.OverlapSphereNonAlloc(position, m_OurCollider.radius, m_CollisionCache, m_CollisionMask); - for (int i = 0; i < numCollisions; i++) - { - int layerTest = 1 << m_CollisionCache[i].gameObject.layer; - - if ((layerTest & m_BlockerMask) != 0) - { - //hit a wall; leave it for a couple of seconds. - m_ProjectileInfo.Speed_m_s = 0; - m_IsDead = true; - m_DestroyAtSec = Time.fixedTime + k_WallLingerSec; - return; - } - - if (m_CollisionCache[i].gameObject.layer == m_NPCLayer && !m_HitTargets.Contains(m_CollisionCache[i].gameObject)) - { - m_HitTargets.Add(m_CollisionCache[i].gameObject); - - if (m_HitTargets.Count >= m_ProjectileInfo.MaxVictims) - { - // we've hit all the enemies we're allowed to! So we're done - m_DestroyAtSec = Time.fixedTime + k_EnemyLingerSec; - m_IsDead = true; - } - - //all NPC layer entities should have one of these. - var targetNetObj = m_CollisionCache[i].GetComponent(); - if (targetNetObj) - { - m_NetState.RecvHitEnemyClientRPC(targetNetObj.NetworkObjectId); - - //retrieve the person that created us, if he's still around. - NetworkObject spawnerNet; - NetworkSpawnManager.SpawnedObjects.TryGetValue(m_SpawnerId, out spawnerNet); - ServerCharacter spawnerObj = spawnerNet != null ? spawnerNet.GetComponent() : null; - - targetNetObj.GetComponent().ReceiveHP(spawnerObj, -m_ProjectileInfo.Damage); - } - - if (m_IsDead) - return; // don't keep examining collisions since we can't damage anybody else - } - } - } - } -} - +```csharp reference +https://github.com/Unity-Technologies/com.unity.multiplayer.samples.coop/blob/develop/Assets/BossRoom/Scripts/Server/Game/Entity/ServerProjectileLogic.cs ``` - We could have used an `RPC` instead, for example the Mage's projectile attack. Since it is expected for that projectile to be quick, we are not affected by the few milliseconds where a newly connected client could miss the projectile and we save on bandwidth having to manage a replicated object. Instead a single RPC is sent to trigger the FX client side. - -```csharp -using MLAPI; -using MLAPI.Spawning; -using UnityEngine; - -namespace BossRoom.Server -{ - /// - /// Action that represents an always-hit raybeam-style ranged attack. A particle is shown from caster to target, and then the - /// target takes damage. (It is not possible to escape the hit; the target ALWAYS takes damage.) This is intended for fairly - /// swift particles; the time before it's applied is based on a simple distance-check at the attack's start. - /// (If no target is provided, it means the user clicked on an empty spot on the map. In that case we still perform an action, - /// it just hits nothing.) - /// - public class FXProjectileTargetedAction : Action - { - private bool m_ImpactedTarget; - private float m_TimeUntilImpact; - private IDamageable m_Target; - - public FXProjectileTargetedAction(ServerCharacter parent, ref ActionRequestData data) : base(parent, ref data) { } - - public override bool Start() - { - m_Target = GetTarget(); - if (m_Target == null && HasTarget()) - { - // target has disappeared! Abort. - return false; - } - - Vector3 targetPos = HasTarget() ? m_Target.transform.position : m_Data.Position; - - // turn to face our target! - m_Parent.transform.LookAt(targetPos); - - // figure out how long the pretend-projectile will be flying to the target - float distanceToTargetPos = Vector3.Distance(targetPos, m_Parent.transform.position); - m_TimeUntilImpact = Description.ExecTimeSeconds + (distanceToTargetPos / Description.Projectiles[0].Speed_m_s); - m_Parent.NetState.RecvDoActionClientRPC(Data); - return true; - } - - public override bool Update() - { - if (!m_ImpactedTarget && m_TimeUntilImpact <= (Time.time - TimeStarted)) - { - m_ImpactedTarget = true; - if (m_Target != null ) - { - m_Target.ReceiveHP(m_Parent, -Description.Projectiles[0].Damage); - } - } - return true; - } - - public override void Cancel() - { - // TODO: somehow tell the corresponding FX to abort! - } - - /// - /// Are we even supposed to have a target? (If not, we're representing a "missed" bolt that just hits nothing.) - /// - private bool HasTarget() - { - return Data.TargetIds != null && Data.TargetIds.Length > 0; - } - - /// - /// Returns our intended target, or null if not found/no target. - /// - private IDamageable GetTarget() - { - if (Data.TargetIds == null || Data.TargetIds.Length == 0) - { - return null; - } - - NetworkObject obj; - if (NetworkSpawnManager.SpawnedObjects.TryGetValue(Data.TargetIds[0], out obj) && obj != null) - { - return obj.GetComponent(); - } - else - { - // target could have legitimately disappeared in the time it took to queue this action... but that's pretty unlikely, so we'll log about it to ease debugging - Debug.Log($"FXProjectileTargetedAction was targeted at ID {Data.TargetIds[0]}, but that target can't be found in spawned object list! (May have just been deleted?)"); - return null; - } - } - } -} - ``` - - ## Character life state We could have used a "kill" `RPC` to set a character as dead and play the appropriate animations. Applying our "should that information be replicated when a player joins the game mid-game" rule of thumb, we used `NetworkVariable`s instead. We used the `OnValueChanged` callback on those values to play our state changes animation. - -```csharp -public NetworkedVar NetworkLifeState { get; } = new NetworkedVar(LifeState.Alive); ``` The animation change: - -```csharp -m_NetState.NetworkLifeState.OnValueChanged += OnLifeStateChanged; + ``` :::tip Lesson Learned @@ -739,286 +65,8 @@ m_NetState.NetworkLifeState.OnValueChanged += OnLifeStateChanged; ![imp not appearing dead](/img/01_imp_not_appearing_dead.png) - -```csharp -using BossRoom.Client; -using Cinemachine; -using MLAPI; -using System; -using System.Collections; -using System.ComponentModel; -using UnityEngine; - -namespace BossRoom.Visual -{ - /// - /// is responsible for displaying a character on the client's screen based on state information sent by the server. - /// - public class ClientCharacterVisualization : NetworkBehaviour - { - private NetworkCharacterState m_NetState; - - [SerializeField] - private Animator m_ClientVisualsAnimator; - - [SerializeField] - private CharacterSwap m_CharacterSwapper; - - [Tooltip("Prefab for the Target Reticule used by this Character")] - public GameObject TargetReticule; - - [Tooltip("Material to use when displaying a friendly target reticule (e.g. green color)")] - public Material ReticuleFriendlyMat; - - [Tooltip("Material to use when displaying a hostile target reticule (e.g. red color)")] - public Material ReticuleHostileMat; - - public Animator OurAnimator { get { return m_ClientVisualsAnimator; } } - - private ActionVisualization m_ActionViz; - - public Transform Parent { get; private set; } - - private const float k_MaxRotSpeed = 280; //max angular speed at which we will rotate, in degrees/second. - - /// Player characters need to report health changes and chracter info to the PartyHUD - private Visual.PartyHUD m_PartyHUD; - - private float m_SmoothedSpeed; - - int m_AliveStateTriggerID; - int m_FaintedStateTriggerID; - int m_DeadStateTriggerID; - int m_HitStateTriggerID; - - /// - public override void NetworkStart() - { - if (!IsClient || transform.parent == null) - { - enabled = false; - return; - } - - m_AliveStateTriggerID = Animator.StringToHash("StandUp"); - m_FaintedStateTriggerID = Animator.StringToHash("FallDown"); - m_DeadStateTriggerID = Animator.StringToHash("Dead"); - m_HitStateTriggerID = Animator.StringToHash(ActionFX.k_DefaultHitReact); - - m_ActionViz = new ActionVisualization(this); - - Parent = transform.parent; - - m_NetState = Parent.gameObject.GetComponent(); - m_NetState.DoActionEventClient += PerformActionFX; - m_NetState.CancelAllActionsEventClient += CancelAllActionFXs; - m_NetState.CancelActionsByTypeEventClient += CancelActionFXByType; - m_NetState.NetworkLifeState.OnValueChanged += OnLifeStateChanged; - m_NetState.OnPerformHitReaction += OnPerformHitReaction; - m_NetState.OnStopChargingUpClient += OnStoppedChargingUp; - m_NetState.IsStealthy.OnValueChanged += OnStealthyChanged; - // With this call, players connecting to a game with down imps will see all of them do the "dying" animation. - // we should investigate for a way to have the imps already appear as down when connecting. - // todo gomps-220 - OnLifeStateChanged(m_NetState.NetworkLifeState.Value, m_NetState.NetworkLifeState.Value); - - //we want to follow our parent on a spring, which means it can't be directly in the transform hierarchy. - Parent.GetComponent().ChildVizObject = this; - transform.SetParent(null); - - // sync our visualization position & rotation to the most up to date version received from server - var parentMovement = Parent.GetComponent(); - transform.position = parentMovement.NetworkPosition.Value; - transform.rotation = Quaternion.Euler(0, parentMovement.NetworkRotationY.Value, 0); - - // sync our animator to the most up to date version received from server - SyncEntryAnimation(m_NetState.NetworkLifeState.Value); - - // listen for char-select info to change (in practice, this info doesn't - // change, but we may not have the values set yet) ... - m_NetState.CharacterAppearance.OnValueChanged += OnCharacterAppearanceChanged; - - // ...and visualize the current char-select value that we know about - OnCharacterAppearanceChanged(0, m_NetState.CharacterAppearance.Value); - - // ...and visualize the current char-select value that we know about - SetAppearanceSwap(); - - if (!m_NetState.IsNpc) - { - // track health for heroes - m_NetState.HealthState.HitPoints.OnValueChanged += OnHealthChanged; - - // find the emote bar to track its buttons - GameObject partyHUDobj = GameObject.FindGameObjectWithTag("PartyHUD"); - m_PartyHUD = partyHUDobj.GetComponent(); - - if (IsLocalPlayer) - { - ActionRequestData data = new ActionRequestData { ActionTypeEnum = ActionType.GeneralTarget }; - m_ActionViz.PlayAction(ref data); - gameObject.AddComponent(); - m_PartyHUD.SetHeroData(m_NetState); - } - else - { - m_PartyHUD.SetAllyData(m_NetState); - } - } - } - - /// - /// The switch to certain LifeStates fires an animation on an NPC/PC. This bypasses that initial animation - /// and sends an NPC/PC to their eventual looping animation. This is necessary for mid-game player connections. - /// - /// The last LifeState received by server. - void SyncEntryAnimation(LifeState lifeState) - { - switch (lifeState) - { - case LifeState.Dead: // ie. NPCs already dead - m_ClientVisualsAnimator.SetTrigger(Animator.StringToHash("EntryDeath")); - break; - case LifeState.Fainted: // ie. PCs already fainted - m_ClientVisualsAnimator.SetTrigger(Animator.StringToHash("EntryFainted")); - break; - } - } - private void OnDestroy() - { - if (m_NetState) - { - m_NetState.DoActionEventClient -= PerformActionFX; - m_NetState.CancelAllActionsEventClient -= CancelAllActionFXs; - m_NetState.CancelActionsByTypeEventClient -= CancelActionFXByType; - m_NetState.NetworkLifeState.OnValueChanged -= OnLifeStateChanged; - m_NetState.OnPerformHitReaction -= OnPerformHitReaction; - m_NetState.OnStopChargingUpClient -= OnStoppedChargingUp; - m_NetState.IsStealthy.OnValueChanged -= OnStealthyChanged; - } - } - - private void OnPerformHitReaction() - { - m_ClientVisualsAnimator.SetTrigger(m_HitStateTriggerID); - } - - private void PerformActionFX(ActionRequestData data) - { - m_ActionViz.PlayAction(ref data); - } - - private void CancelAllActionFXs() - { - m_ActionViz.CancelAllActions(); - } - - private void CancelActionFXByType(ActionType actionType) - { - m_ActionViz.CancelAllActionsOfType(actionType); - } - - private void OnStoppedChargingUp() - { - m_ActionViz.OnStoppedChargingUp(); - } - - private void OnLifeStateChanged(LifeState previousValue, LifeState newValue) - { - switch (newValue) - { - case LifeState.Alive: - m_ClientVisualsAnimator.SetTrigger(m_AliveStateTriggerID); - break; - case LifeState.Fainted: - m_ClientVisualsAnimator.SetTrigger(m_FaintedStateTriggerID); - break; - case LifeState.Dead: - m_ClientVisualsAnimator.SetTrigger(m_DeadStateTriggerID); - break; - default: - throw new ArgumentOutOfRangeException(nameof(newValue), newValue, null); - } - } - - private void OnHealthChanged(int previousValue, int newValue) - { - // don't do anything if party HUD goes away - can happen as Dungeon scene is destroyed - if (m_PartyHUD == null) { return; } - - if (IsLocalPlayer) - { - this.m_PartyHUD.SetHeroHealth(newValue); - } - else - { - this.m_PartyHUD.SetAllyHealth(m_NetState.NetworkObjectId, newValue); - } - } - - private void OnCharacterAppearanceChanged(int oldValue, int newValue) - { - SetAppearanceSwap(); - } - - private void OnStealthyChanged(byte oldValue, byte newValue) - { - SetAppearanceSwap(); - } - - private void SetAppearanceSwap() - { - if (m_CharacterSwapper) - { - if (m_NetState.IsStealthy.Value != 0 && !m_NetState.IsOwner) - { - // this character is in "stealth mode", so other players can't see them! - m_CharacterSwapper.SwapAllOff(); - } - else - { - m_CharacterSwapper.SwapToModel(m_NetState.CharacterAppearance.Value); - } - } - } - - void Update() - { - if (Parent == null) - { - // since we aren't in the transform hierarchy, we have to explicitly die when our parent dies. - Destroy(gameObject); - return; - } - - VisualUtils.SmoothMove(transform, Parent.transform, Time.deltaTime, ref m_SmoothedSpeed, k_MaxRotSpeed); - - if (m_ClientVisualsAnimator) - { - // set Animator variables here - float visibleSpeed = 0; - if (m_NetState.NetworkLifeState.Value == LifeState.Alive) - { - visibleSpeed = m_NetState.VisualMovementSpeed.Value; - } - m_ClientVisualsAnimator.SetFloat("Speed", visibleSpeed); - } - - m_ActionViz.Update(); - } - - public void OnAnimEvent(string id) - { - //if you are trying to figure out who calls this method, it's "magic". The Unity Animation Event system takes method names as strings, - //and calls a method of the same name on a component on the same GameObject as the Animator. See the "attack1" Animation Clip as one - //example of where this is configured. - - m_ActionViz.OnAnimEvent(id); - } - } -} ``` ::: diff --git a/docs/learn/rpcvnetvar.md b/docs/learn/rpcvnetvar.md index 89ac16e80..34d19b5bc 100644 --- a/docs/learn/rpcvnetvar.md +++ b/docs/learn/rpcvnetvar.md @@ -37,25 +37,8 @@ If we sent an `RPC` to all clients, then all players connecting mid game after t In that case, it is preferable to use `NetworkVariable`s like shown here. - -```csharp -using MLAPI; -using MLAPI.NetworkVariable; -using System.Collections; -using UnityEngine; - -/// -/// Network state for a door which can be opened by pressing on a floor switch. -/// -public class NetworkDoorState : NetworkBehaviour -{ - public NetworkVariableBool IsOpen { get; } = new NetworkVariableBool(); -} - ``` It uses a `BoolNetworkVariable` to represent the "IsOpen" state. If I open the door and a player connects after this, the host will replicate all the world's information to that new player, including the door's state. @@ -73,203 +56,22 @@ Actions in Boss Room are a great example for this. The area of effect action (`A `AoeActionInput.cs` Shows the input being updated client side and not waiting for the server. It then calls an `RPC` when clicking on the area to affect. - -```csharp - -using UnityEngine; - -namespace BossRoom.Visual -{ - /// - /// This class is the first step in AoE ability. It will update the initial input visuals' position and will be in charge - /// of tracking the user inputs. Once the ability - /// is confirmed and the mouse is clicked, it'll send the appropriate RPC to the server, triggering the AoE serer side gameplay logic. - /// The server side gameplay action will then trigger the client side resulting FX. - /// This action's flow is this: (Client) AoEActionInput --> (Server) AoEAction --> (Client) AoEActionFX - /// - public class AoeActionInput : BaseActionInput - { - [SerializeField] - private GameObject m_InRangeVisualization; - - [SerializeField] - private GameObject m_OutOfRangeVisualization; - - Camera m_Camera; - int m_GroundLayerMask; - Vector3 m_Origin; - - RaycastHit[] m_UpdateResult = new RaycastHit[1]; - - void Start() - { - var radius = GameDataSource.Instance.ActionDataByType[m_ActionType].Radius; - transform.localScale = new Vector3(radius * 2, radius * 2, radius * 2); - m_Camera = Camera.main; - m_GroundLayerMask = LayerMask.GetMask("Ground"); - m_Origin = m_PlayerOwner.transform.position; - } - - void Update() - { - if (Physics.RaycastNonAlloc( - ray: m_Camera.ScreenPointToRay(Input.mousePosition), - results: m_UpdateResult, - maxDistance: float.PositiveInfinity, - layerMask: m_GroundLayerMask) > 0) - { - transform.position = m_UpdateResult[0].point; - } - - float range = GameDataSource.Instance.ActionDataByType[m_ActionType].Range; - bool isInRange = (m_Origin - transform.position).sqrMagnitude <= range * range; - m_InRangeVisualization.SetActive(isInRange); - m_OutOfRangeVisualization.SetActive(!isInRange); - - if (Input.GetMouseButtonUp(0)) - { - if (isInRange) - { - var data = new ActionRequestData - { - Position = transform.position, - ActionTypeEnum = m_ActionType, - ShouldQueue = false, - TargetIds = null - }; - m_PlayerOwner.RecvDoActionServerRPC(data); - } - Destroy(gameObject); - return; - } - } - } -} ``` `AOEAction.cs` Server side logic detecting enemies inside the area and applying damage. It then broadcasts an `RPC` to tell all clients to play the VFX at the appropriate position. Character's state will automatically update with their respective `NetworkVariable`s update (health and alive status for example). - -```csharp -using BossRoom; -using BossRoom.Server; -using MLAPI; -using MLAPI.Spawning; -using System.Collections.Generic; -using UnityEngine; - -/// -/// Area-of-effect attack Action. The attack is centered on a point provided by the client. -/// -public class AoeAction : Action -{ - /// - /// Cheat prevention: to ensure that players don't perform AoEs outside of their attack range, - /// we ensure that the target is less than Range meters away from the player, plus this "fudge - /// factor" to accomodate miscellaneous minor movement. - /// - const float k_MaxDistanceDivergence = 1; - - bool m_DidAoE; - - public AoeAction(ServerCharacter parent, ref ActionRequestData data) - : base(parent, ref data) { } - - public override bool Start() - { - float distanceAway = Vector3.Distance(m_Parent.transform.position, Data.Position); - if (distanceAway > Description.Range+k_MaxDistanceDivergence) - { - Debug.LogError($"Hacking detected?! (Object ID {m_Parent.NetworkObjectId}) {Description.ActionTypeEnum}'s AoE range is {Description.Range} but we were given a point that's {distanceAway} away from us. Canceling AoE"); - return ActionConclusion.Stop; - } - - // broadcasting to all players including myself. - // We don't know our actual targets for this attack until it triggers, so the client can't use the TargetIds list (and we clear it out for clarity). - // This means we are responsible for triggering reaction-anims ourselves, which we do in PerformAoe() - Data.TargetIds = new ulong[0]; - m_Parent.NetState.RecvDoActionClientRPC(Data); - return ActionConclusion.Continue; - } - - public override bool Update() - { - if (TimeRunning >= Description.ExecTimeSeconds && !m_DidAoE) - { - // actually perform the AoE attack - m_DidAoE = true; - PerformAoE(); - } - return ActionConclusion.Continue; - } - - private void PerformAoE() - { - // Note: could have a non alloc version of this overlap sphere where we statically store our collider array, but since this is a self - // destroyed object, the complexity added to have a static pool of colliders that could be called by multiplayer players at the same time - // doesn't seem worth it for now. - var colliders = Physics.OverlapSphere(m_Data.Position, Description.Radius, LayerMask.GetMask("NPCs")); - for (var i = 0; i < colliders.Length; i++) - { - var enemy = colliders[i].GetComponent(); - if (enemy != null) - { - // make the target "flinch", assuming they're a living enemy - var networkObject = NetworkSpawnManager.SpawnedObjects[enemy.NetworkObjectId]; - if (networkObject) - { - var state = networkObject.GetComponent(); - if (state) - { - state.RecvPerformHitReactionClientRPC(); - } - } - - // actually deal the damage - enemy.ReceiveHP(m_Parent, -Description.Amount); - } - } - } -} ``` `AoeActionFX.cs` is triggered by an `RPC` coming from the server - -```csharp -using System; -using UnityEngine; - -namespace BossRoom.Visual -{ - /// Final step in the AoE action flow. Please see AoEActionInput for the first step and more details on overall flow - public class AoeActionFX : ActionFX - { - public AoeActionFX(ref ActionRequestData data, ClientCharacterVisualization parent) - : base(ref data, parent) { } - - public override bool Start() - { - m_Parent.OurAnimator.SetTrigger(Description.Anim); - GameObject.Instantiate(Description.Spawns[0], m_Data.Position, Quaternion.identity); - return ActionConclusion.Stop; - } - - public override bool Update() - { - throw new Exception("This should not execute"); - } - } -} - ``` :::tip diff --git a/docs/tutorials/helloworldparttwo.md b/docs/tutorials/helloworldparttwo.md index c7d0eb6d0..6065a98d3 100644 --- a/docs/tutorials/helloworldparttwo.md +++ b/docs/tutorials/helloworldparttwo.md @@ -23,126 +23,25 @@ You can copy the script from here and paste it into your file. 1. Click **Copy** in the top right corner. 1. Paste it into your code editor. ::: - -```csharp - -using MLAPI; -using UnityEngine; - -namespace HelloWorld -{ - public class HelloWorldManager : MonoBehaviour - { - void OnGUI() - { - GUILayout.BeginArea(new Rect(10, 10, 300, 300)); - if (!NetworkManager.Singleton.IsClient && !NetworkManager.Singleton.IsServer) - { - StartButtons(); - } - else - { - StatusLabels(); - - SubmitNewPosition(); - } - - GUILayout.EndArea(); - } - - static void StartButtons() - { - if (GUILayout.Button("Host")) NetworkManager.Singleton.StartHost(); - if (GUILayout.Button("Client")) NetworkManager.Singleton.StartClient(); - if (GUILayout.Button("Server")) NetworkManager.Singleton.StartServer(); - } - - static void StatusLabels() - { - var mode = NetworkManager.Singleton.IsHost ? - "Host" : NetworkManager.Singleton.IsServer ? "Server" : "Client"; - - GUILayout.Label("Transport: " + - NetworkManager.Singleton.NetworkConfig.NetworkTransport.GetType().Name); - GUILayout.Label("Mode: " + mode); - } - - static void SubmitNewPosition() - { - if (GUILayout.Button(NetworkManager.Singleton.IsServer ? "Move" : "Request Position Change")) - { - if (NetworkManager.Singleton.ConnectedClients.TryGetValue(NetworkManager.Singleton.LocalClientId, - out var networkedClient)) - { - var player = networkedClient.PlayerObject.GetComponent(); - if (player) - { - player.Move(); - } - } - } - } - } -} +```csharp reference +https://github.com/Unity-Technologies/com.unity.multiplayer.samples.poc/tree/feature/hello-world/Assets/Scripts/Shared/HelloWorldManager.cs ``` Inside the `HelloWorldManager.cs` script, we will define two methods which mimic the editor buttons inside of **NetworkManager** during Play mode. - -```csharp - static void StartButtons() - { - if (GUILayout.Button("Host")) NetworkManager.Singleton.StartHost(); - if (GUILayout.Button("Client")) NetworkManager.Singleton.StartClient(); - if (GUILayout.Button("Server")) NetworkManager.Singleton.StartServer(); - } - - static void StatusLabels() - { - var mode = NetworkManager.Singleton.IsHost ? - "Host" : NetworkManager.Singleton.IsServer ? "Server" : "Client"; - - GUILayout.Label("Transport: " + - NetworkManager.Singleton.NetworkConfig.NetworkTransport.GetType().Name); - GUILayout.Label("Mode: " + mode); - } -``` - `NetworkManager` implements the singleton pattern as it declares its singleton named `Singleton`. This is defined when the `MonoBehaviour` is enabled. This component also contains very useful properties, such as `IsClient`, `IsServer`, and `IsLocalClient`. The first two dictate the connection state we have currently established that you will use shortly. We will call these methods inside of `OnGUI()`. - - -```csharp - -void OnGUI() - { - GUILayout.BeginArea(new Rect(10, 10, 300, 300)); - if (!NetworkManager.Singleton.IsClient && !NetworkManager.Singleton.IsServer) - { - StartButtons(); - } - else - { - StatusLabels(); - - SubmitNewPosition(); - } - - GUILayout.EndArea(); - } - ``` :::note You will notice the introduction of a new method, `SubmitNewPosition()`; which we will be using later. @@ -153,104 +52,38 @@ You will notice the introduction of a new method, `SubmitNewPosition()`; which 1. Open the `HelloWorldPlayer.cs` script. 1. Edit the `HelloWorldPlayer.cs` script to match the following. -```csharp - -using MLAPI; -using MLAPI.Messaging; -using MLAPI.NetworkVariable; -using UnityEngine; - -namespace HelloWorld -{ - public class HelloWorldPlayer : NetworkBehaviour - { - public NetworkVariableVector3 Position = new NetworkVariableVector3(new NetworkVariableSettings - { - WritePermission = NetworkVariablePermission.ServerOnly, - ReadPermission = NetworkVariablePermission.Everyone - }); - - public override void NetworkStart() - { - Move(); - } - - public void Move() - { - if (NetworkManager.Singleton.IsServer) - { - var randomPosition = GetRandomPositionOnPlane(); - transform.position = randomPosition; - Position.Value = randomPosition; - } - else - { - SubmitPositionRequestServerRpc(); - } - } - - [ServerRpc] - void SubmitPositionRequestServerRpc(ServerRpcParams rpcParams = default) - { - Position.Value = GetRandomPositionOnPlane(); - } - - static Vector3 GetRandomPositionOnPlane() - { - return new Vector3(Random.Range(-3f, 3f), 1f, Random.Range(-3f, 3f)); - } - - void Update() - { - transform.position = Position.Value; - } - } -} +```csharp reference +https://github.com/Unity-Technologies/com.unity.multiplayer.samples.poc/tree/feature/hello-world/Assets/Scripts/Shared/HelloWorldPlayer.cs ``` + 11. Select the Player Prefab. 1. Add the script `HelloWorldPlayer` script as a component. ![Create a Helloworldplayer script](/img/helloworldcreateplayerscript.gif) This class will inherit from `NetworkBehaviour` instead of `MonoBehaviour`. - - -```csharp - public class HelloWorldPlayer : NetworkBehaviour - ``` + Inside this class we will define a `NetworkVariable` to represent this player's networked position. - -```csharp - public NetworkVariableVector3 Position = new NetworkVariableVector3(new NetworkVariableSettings - { - WritePermission = NetworkVariablePermission.ServerOnly, - ReadPermission = NetworkVariablePermission.Everyone - }); -``` + We introduce the concept of ownership on a `NetworkVariable` (read and write permissions). For the purposes of this demo, the server will be authoritative on the `NetworkVariable` representing position. All clients are able to read the value, however. `HelloWorldPlayer` overrides `NetworkStart`. - -```csharp - public override void NetworkStart() - { - Move(); - } -``` + + Any `MonoBehaviour` implementing `NetworkBehaviour` can override the MLAPI method `NetworkStart()`. This method is fired when message handlers are ready to be registered and the networking is setup. We override `NetworkStart` since a client and a server will run different logic here. :::note @@ -258,111 +91,52 @@ This can be overriden on any `NetworkBehaviour`. ::: On both client and server instances of this player, we call the `Move()` method, which will simply do the following. - - -```csharp - public void Move() - { - if (NetworkManager.Singleton.IsServer) - { - var randomPosition = GetRandomPositionOnPlane(); - transform.position = randomPosition; - Position.Value = randomPosition; - } - else - { - SubmitPositionRequestServerRpc(); - } - } -``` + + + If this player is a server-owned player, at `NetworkStart()` we can immediately move this player, as suggested in the following code. - - -```csharp - if (NetworkManager.Singleton.IsServer) - { - var randomPosition = GetRandomPositionOnPlane(); - transform.position = randomPosition; - Position.Value = randomPosition; - } -``` + If we are a client, we call a server RPC. - -```csharp - else - { - SubmitPositionRequestServerRpc(); - } -``` This server RPC simply sets the position `NetworkVariable` on the server's instance of this player by just picking a random point on the plane. - -```csharp - [ServerRpc] - void SubmitPositionRequestServerRpc(ServerRpcParams rpcParams = default) - { - Position.Value = GetRandomPositionOnPlane(); - } -``` + + The server instance of this player has just modified the Position NetworkVariable, meaning that if we are a client, we need to apply this position locally inside of our Update loop. - -```csharp - void Update() - { - transform.position = Position.Value; - } -``` + + We can now go back to `HelloWorldManager.cs` and define the contents of `SubmitNewPosition()`. - -```csharp - static void SubmitNewPosition() - { - if (GUILayout.Button(NetworkManager.Singleton.IsServer ? "Move" : "Request Position Change")) - { - if (NetworkManager.Singleton.ConnectedClients.TryGetValue(NetworkManager.Singleton.LocalClientId, - out var networkedClient)) - { - var player = networkedClient.PlayerObject.GetComponent(); - if (player) - { - player.Move(); - } - } - } - } + ``` Whenever you press the GUI button (which is contextual depending on if you are server or a client), you find your local player and simply call `Move()`.