Files
jelito/Assets/jelycho/Code/Enemies/Zombie/ZombieActor.cs
2025-10-24 13:54:27 +02:00

443 lines
14 KiB
C#

using System;
using System.Runtime.CompilerServices;
using Animancer;
using RebootKit.Engine.AI;
using RebootKit.Engine.Extensions;
using RebootKit.Engine.Main;
using RebootKit.Engine.Network;
using RebootKit.Engine.Simulation;
using RebootReality.jelycho.Actors;
using RebootReality.jelycho.Beacons;
using RebootReality.jelycho.Main;
using RebootReality.jelycho.Player;
using TriInspector;
using Unity.Mathematics;
using UnityEngine;
using UnityEngine.AI;
using Logger = RebootKit.Engine.Foundation.Logger;
using Random = UnityEngine.Random;
using UnityEvent = UnityEngine.Events.UnityEvent;
namespace RebootReality.jelycho.Enemies.Zombie {
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;
}
}
}
class ZombiePickVictim : IStrategy {
public BehaviourNode.Status Process(Actor target, float dt) {
if (target is not ZombieActor zombie) {
return BehaviourNode.Status.Failure;
}
Actor victim = zombie.FindNewVictim();
if (victim == null) {
return BehaviourNode.Status.Failure;
}
return BehaviourNode.Status.Success;
}
}
class ZombieGoToPlayer : IStrategy {
public BehaviourNode.Status Process(Actor target, float dt) {
if (target is not ZombieActor zombie) {
return BehaviourNode.Status.Failure;
}
if (!zombie.HasTravelDestination) {
float3 victimPos = zombie.Victim.transform.position;
float dstToVictimSq = math.distancesq(victimPos, zombie.transform.position);
if (dstToVictimSq < 1.0f) {
return BehaviourNode.Status.Success;
}
zombie.GoTo(victimPos);
return BehaviourNode.Status.Running;
}
return BehaviourNode.Status.Running;
}
}
[DeclareBoxGroup("Body parts")]
[DeclareBoxGroup("Animations")]
public class ZombieActor : Actor, IHasHealth {
static readonly Logger s_Logger = new Logger(nameof(ZombieActor));
[SerializeField] AnimancerComponent m_Animancer;
[SerializeField] NavMeshAgent m_NavAgent;
public NavMeshAgent NavAgent {
get {
return m_NavAgent;
}
}
[SerializeField] Collider m_RootCollider;
[SerializeField] Rigidbody[] m_RagdollRigidbodies;
[SerializeField] float m_MaxAttackDistance = 2.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;
[SerializeField, Group("Animations")] TransitionAsset m_GroundLocomotion;
[SerializeField, Group("Animations")] StringAsset m_GroundLocomotionPropertyRight;
[SerializeField, Group("Animations")] StringAsset m_GroundLocomotionPropertyForward;
SmoothedVector2Parameter m_SmoothLocomotionDirection;
[SerializeField, Group("Animations")] AnimationClip[] m_AttackClips;
BehaviourTree m_BehaviourTree;
public enum MindState {
Normal,
RunAway,
Berserk
}
public bool IsRagdoll { get; private set; } = false;
public MindState Mind { get; private set; } = MindState.Normal;
public Actor Victim { get; private set; }
public bool HasTravelDestination { get; private set; }
public float3 TravelDestination { get; private set; }
public UnityEvent died = new UnityEvent();
//
// @MARK: Unity callbacks
//
void Awake() {
SetRagdollLocal(IsRagdoll);
m_SmoothLocomotionDirection = new SmoothedVector2Parameter(m_Animancer,
m_GroundLocomotionPropertyRight,
m_GroundLocomotionPropertyForward,
0.1f);
m_Animancer.Play(m_GroundLocomotion);
m_BehaviourTree = new BehaviourTree("Zombie Behaviour");
var rootSelector = new Selector("Root");
m_BehaviourTree.AddChild(rootSelector);
rootSelector.AddChild(CreateNormalSequence());
}
BehaviourNode CreateNormalSequence() {
var normalSequence = new Sequence("Normal", () => Mind == MindState.Normal);
normalSequence.AddChild(new Leaf("Pick Victim", new ZombiePickVictim()));
var attackPlayerSequence = new Sequence("Attack Player", IsVictimPlayer);
normalSequence.AddChild(attackPlayerSequence);
attackPlayerSequence.AddChild(new Leaf("Go to Player", new ZombieGoToPlayer()));
var attackMotherSequence = new Sequence("Attack Mother", IsVictimMother);
normalSequence.AddChild(attackMotherSequence);
return normalSequence;
}
//
// @MARK: Actor
//
public override void OnClientTick(float deltaTime) {
base.OnClientTick(deltaTime);
if (!IsAlive()) {
return;
}
float3 vel = m_NavAgent.velocity;
float forwardVelocity = math.dot(m_NavAgent.transform.forward, vel);
float rightVelocity = math.dot(m_NavAgent.transform.right, vel);
m_SmoothLocomotionDirection.TargetValue = new Vector2(rightVelocity, forwardVelocity);
}
public override void OnServerTick(float deltaTime) {
base.OnServerTick(deltaTime);
if (RR.World.Context is not WorldContext world) {
s_Logger.Error("Invalid world context");
return;
}
if (HasTravelDestination) {
float3 pos = transform.position;
if (math.distancesq(pos, TravelDestination) <= 1.0f) {
HasTravelDestination = false;
m_NavAgent.isStopped = true;
}
}
m_BehaviourTree.Process(this, deltaTime);
}
//
// @MARK: Zombie
//
public bool GoTo(float3 pos) {
if (!RR.IsServer()) {
s_Logger.Error("Only server can call GoTo");
return false;
}
TravelDestination = pos;
HasTravelDestination = true;
m_NavAgent.isStopped = false;
return m_NavAgent.SetDestination(TravelDestination);
}
public bool IsVictimPlayer() {
return Victim is PlayerActor;
}
public bool IsVictimMother() {
return Victim is MotherActor;
}
public Actor FindNewVictim() {
if (!RR.IsServer()) {
s_Logger.Error("Only server can call FindNewVictim");
return null;
}
Victim = null;
(PlayerActor playerActor, float distSqToPlayer) = FindClosestPlayerActor(transform.position);
if (playerActor != null && distSqToPlayer < m_LoseInterestMinDistance * m_LoseInterestMinDistance) {
Victim = playerActor;
return Victim;
}
if (RR.World.Context is WorldContext ctx) {
Victim = ctx.BaseManager.Mother;
return Victim;
}
return null;
}
public void PerformAttack() {
m_Animancer.Play(m_AttackClips.Random());
}
void Die() {
EnableRagdoll();
m_NavAgent.enabled = false;
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) {
var 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_Animancer.enabled = !active;
}
//
// @MARK: IHasHealth
//
public ulong Health { get; private set; } = 100;
public ulong MaxHealth { get; private set; } = 100;
public bool IsAlive() {
return Health > 0;
}
//
// @MARK: damage?
//
public void HitFeedback(float3 hitPosition) {
if (RR.World.Context is WorldContext worldContext) {
worldContext.FeedbacksManager.SpawnBloodSplash(hitPosition);
}
}
public void ReceiveBodyPartDamage(ulong damage, ZombieBodyPartType bodyPartType) {
if (!RR.IsServer()) {
s_Logger.Error("ReceiveBodyPartDamage can only be called on the server.");
return;
}
if (!IsAlive()) {
return;
}
ZombieBodyPart bodyPart = GetBodyParty(bodyPartType);
if (!bodyPart.IsAlive()) {
return;
}
bool isBodyPartDestroyed = Random.Range(0.0f, 1.0f) > 0.5f;
if (isBodyPartDestroyed) {
bodyPart.HideParts();
bodyPart.bloodStreamParticles.Play();
if (bodyPartType == ZombieBodyPartType.Head) {
damage = Health;
} else if (bodyPartType == ZombieBodyPartType.LeftLeg ||
bodyPartType == ZombieBodyPartType.RightLeg) {
}
}
damage = math.min(damage, Health);
Health -= damage;
if (Health <= 0) {
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<ZombieHurtbox>()) {
hurtbox.owner = this;
UnityEditor.EditorUtility.SetDirty(hurtbox);
}
}
#endif
}
enum ZombieActorEvents {
None = 0x00,
EnableRagdoll = 0x01
}
}