using System; using System.Runtime.CompilerServices; using RebootKit.Engine.Extensions; using RebootKit.Engine.Main; using RebootKit.Engine.Network; using RebootKit.Engine.Simulation; using RebootReality.jelycho.Player; using TriInspector; using Unity.Mathematics; using UnityEngine; using UnityEngine.AI; using UnityEngine.Events; using Logger = RebootKit.Engine.Foundation.Logger; namespace RebootReality.jelycho.Enemies { public class ZombieActorData : IActorData { public void Serialize(NetworkBufferWriter writer) { } public void Deserialize(NetworkBufferReader reader) { } public int GetMaxBytes() { return 0; } } public enum ZombieBodyPartType { Body, Head, LeftArm, RightArm, LeftLeg, RightLeg } // @TODO: weird naming [Serializable] public class ZombieBodyPart { public Transform root; public Transform[] wearables; public Collider[] colliders; public ParticleSystem bloodStreamParticles; [MethodImpl(MethodImplOptions.AggressiveInlining)] public bool IsAlive() { return root.gameObject.activeSelf; } public void HideParts() { root.gameObject.SetActive(false); foreach (Transform wearable in wearables) { wearable.gameObject.SetActive(false); } foreach (Collider collider in colliders) { collider.enabled = false; } } } [DeclareBoxGroup("Body parts")] public class ZombieActor : Actor, IKillable { static readonly Logger s_Logger = new Logger(nameof(ZombieActor)); static readonly int s_MovementSpeedHash = Animator.StringToHash("MovementSpeed"); enum AIState { Idle, Dead, AttackBase, AttackCharacter, PanicEscape, Berserk } [SerializeField] Animator m_Animator; [SerializeField] NavMeshAgent m_NavAgent; [SerializeField] Collider m_RootCollider; [SerializeField] Rigidbody[] m_RagdollRigidbodies; [SerializeField] float m_MaxAttackDistance = 1.0f; [SerializeField] float m_LoseInterestMinDistance = 10.0f; [SerializeField] ulong m_BaseDamage = 10; [SerializeField] float m_AttackDelay = 1.0f; [SerializeField, Group("Body parts")] ZombieBodyPart m_Head; [SerializeField, Group("Body parts")] ZombieBodyPart m_LeftArm; [SerializeField, Group("Body parts")] ZombieBodyPart m_RightArm; [SerializeField, Group("Body parts")] ZombieBodyPart m_LeftLeg; [SerializeField, Group("Body parts")] ZombieBodyPart m_RightLeg; AIState m_State = AIState.Idle; PlayerActor m_PlayerTarget; float m_NextAttackTimer; public UnityEvent died = new UnityEvent(); // // @MARK: Unity callbacks // void Awake() { SetRagdollLocal(false); } // // @MARK: Actor // public override void OnClientTick(float deltaTime) { base.OnClientTick(deltaTime); if (!IsAlive()) { return; } float velXZ = m_NavAgent.velocity.With(y: 0).magnitude; m_Animator.SetFloat(s_MovementSpeedHash, velXZ); } public override void OnServerTick(float deltaTime) { base.OnServerTick(deltaTime); if (RR.World.Context is not WorldContext world) { s_Logger.Error("Invalid world context"); return; } switch (m_State) { case AIState.Idle: { ServerTickIdle(deltaTime); break; } case AIState.AttackBase: { ServerTickAttackBase(deltaTime); break; } case AIState.AttackCharacter: { ServerTickAttackCharacter(deltaTime); break; } case AIState.PanicEscape: { break; } case AIState.Berserk: { ServerTickBerserk(deltaTime); break; } } } // // @MARK: Zombie // void ServerTickIdle(float dt) { (PlayerActor playerActor, float distSqToPlayer) = FindClosestPlayerActor(transform.position); if (playerActor == null || distSqToPlayer >= m_LoseInterestMinDistance * m_LoseInterestMinDistance) { return; } m_State = AIState.AttackCharacter; m_PlayerTarget = playerActor; s_Logger.Info($"Found player actor to attack: {m_PlayerTarget}"); m_NavAgent.SetDestination(m_PlayerTarget.transform.position); m_NavAgent.isStopped = false; } void ServerTickAttackCharacter(float dt) { if (m_PlayerTarget == null || !m_PlayerTarget.IsAlive()) { SetIdleState(); return; } float3 playerPos = m_PlayerTarget.transform.position; float3 zombiePos = transform.position; float distToPlayerSq = math.distancesq(playerPos, zombiePos); if (distToPlayerSq >= m_LoseInterestMinDistance * m_LoseInterestMinDistance) { SetIdleState(); return; } if (distToPlayerSq <= m_MaxAttackDistance * m_MaxAttackDistance) { m_NextAttackTimer -= dt; if (m_NextAttackTimer <= 0.0f) { m_Animator.CrossFade("Attack_0", 0.0f, 0); m_NextAttackTimer = m_AttackDelay; } if (!m_NavAgent.isStopped) { m_NavAgent.isStopped = true; } return; } float distFromDstToTargetSq = math.distancesq(playerPos, m_NavAgent.destination); if (distFromDstToTargetSq > 1.0f) { m_NavAgent.isStopped = false; m_NavAgent.SetDestination(m_PlayerTarget.transform.position); } } void ServerTickAttackBase(float dt) { } void ServerTickBerserk(float dt) { } void SetIdleState() { m_PlayerTarget = null; m_State = AIState.Idle; } void Die() { s_Logger.Info("Die"); EnableRagdoll(); m_NavAgent.enabled = false; m_State = AIState.Dead; died.Invoke(); } (PlayerActor, float) FindClosestPlayerActor(float3 origin) { if (RR.World.Context is not WorldContext context) { return (null, -1.0f); } PlayerActor res = null; float closestDistanceSq = float.MaxValue; foreach (Actor actor in RR.Actors()) { if (actor is not PlayerActor playerActor) { continue; } float distSq = math.distancesq(actor.transform.position, origin); if (distSq < closestDistanceSq) { res = playerActor; closestDistanceSq = distSq; } } return (res, closestDistanceSq); } // // @MARK: Actor // protected override IActorData CreateActorData() { return new ZombieActorData(); } protected override void OnActorEventClient(ActorEvent actorEvent) { ZombieActorEvents zombieEvent = (ZombieActorEvents) actorEvent.EventID; switch (zombieEvent) { case ZombieActorEvents.EnableRagdoll: { SetRagdollLocal(true); break; } } } // // @MARK: Ragdoll // void EnableRagdoll() { SendActorEvent((byte)ZombieActorEvents.EnableRagdoll); } void SetRagdollLocal(bool active) { m_RootCollider.enabled = !active; foreach (Rigidbody ragdollRigidbody in m_RagdollRigidbodies) { ragdollRigidbody.isKinematic = !active; } m_Animator.enabled = !active; } // // @MARK: IKillable // public ulong Health { get; private set; } = 100; public bool IsAlive() { return Health > 0; } public ulong OnHit(Actor attacker, ulong damage) { if (!RR.IsServer()) { s_Logger.Error("OnHit can only be called on the server."); return 0; } if (!IsAlive()) { return 0; } s_Logger.Info($"Hit: {damage}"); damage = math.min(damage, Health); Health -= damage; if (Health <= 0) { Die(); return damage; } return damage; } // // @MARK: damage? // public void HitFeedback(float3 hitPosition) { if (RR.World.Context is WorldContext worldContext) { worldContext.FeedbacksManager.SpawnBloodSplash(hitPosition); } } public void ReceiveDamage(ulong damage, ZombieBodyPartType bodyPartType) { if (!IsAlive()) { return; } ZombieBodyPart bodyPart = GetBodyParty(bodyPartType); if (!bodyPart.IsAlive()) { return; } bodyPart.HideParts(); bodyPart.bloodStreamParticles.Play(); Die(); } public bool HasBodyPart(ZombieBodyPartType bodyPart) { if (Health <= 0) { return false; } if (bodyPart == ZombieBodyPartType.Body) { return true; } ZombieBodyPart part = GetBodyParty(bodyPart); if (part == null) { return false; } return part.IsAlive(); } ZombieBodyPart GetBodyParty(ZombieBodyPartType bodyPart) { return bodyPart switch { ZombieBodyPartType.Body => null, ZombieBodyPartType.Head => m_Head, ZombieBodyPartType.LeftArm => m_LeftArm, ZombieBodyPartType.RightArm => m_RightArm, ZombieBodyPartType.LeftLeg => m_LeftLeg, ZombieBodyPartType.RightLeg => m_RightLeg, _ => throw new ArgumentOutOfRangeException(nameof(bodyPart), bodyPart, null) }; } /// /// @MARK: Editor Utility /// #if UNITY_EDITOR [Button] void SetupHurtboxes() { foreach (ZombieHurtbox hurtbox in GetComponentsInChildren()) { hurtbox.owner = this; UnityEditor.EditorUtility.SetDirty(hurtbox); } } #endif } enum ZombieActorEvents { None = 0x00, EnableRagdoll = 0x01 } }