using System; using Unity.BossRoom.Gameplay.GameplayObjects; using Unity.BossRoom.Gameplay.GameplayObjects.Character; using UnityEngine; namespace Unity.BossRoom.Gameplay.Actions { /// /// Action that represents a swing of a melee weapon. It is not explicitly targeted, but rather detects the foe that was hit with a physics check. /// /// /// Q: Why do we DetectFoe twice, once in Start, once when we actually connect? /// A: The weapon swing doesn't happen instantaneously. We want to broadcast the action to other clients as fast as possible to minimize latency, /// but this poses a conundrum. At the moment the swing starts, you don't know for sure if you've hit anybody yet. There are a few possible resolutions to this: /// 1. Do the DetectFoe operation once--in Start. /// Pros: Simple! Only one physics cast per swing--saves on perf. /// Cons: Is unfair. You can step out of the swing of an attack, but no matter how far you go, you'll still be hit. The reverse is also true--you can /// "step into an attack", and it won't affect you. This will feel terrible to the attacker. /// 2. Do the DetectFoe operation once--in Update. Send a separate RPC to the targeted entity telling it to play its hit react. /// Pros: Always shows the correct behavior. The entity that gets hit plays its hit react (if any). /// Cons: You need another RPC. Adds code complexity and bandwidth. You also don't have enough information when you start visualizing the swing on /// the client to do any intelligent animation handshaking. If your server->client latency is even a little uneven, your "attack" animation /// won't line up correctly with the hit react, making combat look floaty and disjointed. /// 3. Do the DetectFoe operation twice, once in Start and once in Update. /// Pros: Is fair--you do the hit-detect at the moment of the swing striking home. And will generally play the hit react on the right target. /// Cons: Requires more complicated visualization logic. The initial broadcast foe can only ever be treated as a "hint". The graphics logic /// needs to do its own range checking to pick the best candidate to play the hit react on. /// /// As so often happens in networked games (and games in general), there's no perfect solution--just sets of tradeoffs. For our example, we're showing option "3". /// [CreateAssetMenu(menuName = "BossRoom/Actions/Melee Action")] public partial class MeleeAction : Action { private bool m_ExecutionFired; private ulong m_ProvisionalTarget; public override bool OnStart(ServerCharacter serverCharacter) { ulong target = (Data.TargetIds != null && Data.TargetIds.Length > 0) ? Data.TargetIds[0] : serverCharacter.TargetId.Value; IDamageable foe = DetectFoe(serverCharacter, target); if (foe != null) { m_ProvisionalTarget = foe.NetworkObjectId; Data.TargetIds = new ulong[] { foe.NetworkObjectId }; } // snap to face the right direction if (Data.Direction != Vector3.zero) { serverCharacter.physicsWrapper.Transform.forward = Data.Direction; } serverCharacter.serverAnimationHandler.NetworkAnimator.SetTrigger(Config.Anim); serverCharacter.clientCharacter.ClientPlayActionRpc(Data); return true; } public override void Reset() { base.Reset(); m_ExecutionFired = false; m_ProvisionalTarget = 0; m_ImpactPlayed = false; m_SpawnedGraphics = null; } public override bool OnUpdate(ServerCharacter clientCharacter) { if (!m_ExecutionFired && (Time.time - TimeStarted) >= Config.ExecTimeSeconds) { m_ExecutionFired = true; var foe = DetectFoe(clientCharacter, m_ProvisionalTarget); if (foe != null) { foe.ReceiveHP(clientCharacter, -Config.Amount); } } return true; } /// /// Returns the ServerCharacter of the foe we hit, or null if none found. /// /// private IDamageable DetectFoe(ServerCharacter parent, ulong foeHint = 0) { return GetIdealMeleeFoe(Config.IsFriendly ^ parent.IsNpc, parent.physicsWrapper.DamageCollider, Config.Range, foeHint); } /// /// Utility used by Actions to perform Melee attacks. Performs a melee hit-test /// and then looks through the results to find an alive target, preferring the provided /// enemy. /// /// true if the attacker is an NPC (and therefore should hit PCs). False for the reverse. /// The collider of the attacking GameObject. /// The range in meters to check for foes. /// The NetworkObjectId of our preferred foe, or 0 if no preference /// ideal target's IDamageable, or null if no valid target found public static IDamageable GetIdealMeleeFoe(bool isNPC, Collider ourCollider, float meleeRange, ulong preferredTargetNetworkId) { RaycastHit[] results; int numResults = ActionUtils.DetectMeleeFoe(isNPC, ourCollider, meleeRange, out results); IDamageable foundFoe = null; //everything that got hit by the raycast should have an IDamageable component, so we can retrieve that and see if they're appropriate targets. //we always prefer the hinted foe. If he's still in range, he should take the damage, because he's who the client visualization //system will play the hit-react on (in case there's any ambiguity). for (int i = 0; i < numResults; i++) { var damageable = results[i].collider.GetComponent(); if (damageable != null && damageable.IsDamageable() && (damageable.NetworkObjectId == preferredTargetNetworkId || foundFoe == null)) { foundFoe = damageable; } } return foundFoe; } } }