using System.Collections.Generic; using ProjectI.Combat; using Unity.Netcode; using UnityEngine; using UnityEngine.AI; using ProjectI.Runs; using ProjectI.Combat.Statuses; namespace ProjectI.Enemies { public sealed class PrototypeEnemy : NetworkBehaviour, IAuthoritativeDamageReceiver { public EnemyDefinition definition; public Transform visuals; public Transform hitboxes; public Transform healthBarAnchor; public EnemyPresentation presentation; public BossAttackPresentation bossPresentation; public EnemyIdentity Identity => new EnemyIdentity(TargetId, Lifetime); private readonly NetworkVariable faction = new NetworkVariable(); private EnemyFaction pendingFaction; public EnemyFaction Faction => (EnemyFaction)faction.Value; public double ServerNow => Now; public PrototypeEncounterDirector Encounter => director; private readonly NetworkVariable arrivalSnapshot = new NetworkVariable(); private readonly EnemyArrivalState arrivalState = new EnemyArrivalState(); public EnemyArrivalSnapshot Arrival => arrivalSnapshot.Value; public bool ReadyForActionsServer => IsServer && IsAlive && BehaviorReady && EnemyArrivalState.Ready(Arrival, SpawnIdentity, Now); internal EnemyAdmissionLedger DirectorLedger { get; private set; } internal ulong DirectorAdmissionId { get; private set; } internal int DirectorMemberSlot { get; private set; } internal GameObject DirectorSourcePrefab { get; private set; } public bool ConfigureArrivalBeforeSpawn(double start, double seconds) => !IsSpawned && arrivalState.ConfigureBeforeSpawn(start, seconds); internal bool ConfigureDirectorAdmission(EnemyAdmissionLedger ledger, ulong admission, int slot, GameObject prefab) { if (IsSpawned || DirectorLedger != null || ledger == null || admission == 0 || slot < 0 || prefab == null) return false; DirectorLedger = ledger; DirectorAdmissionId = admission; DirectorMemberSlot = slot; DirectorSourcePrefab = prefab; return true; } public void ConfigureFactionSpawn(EnemyFaction value) { pendingFaction = System.Enum.IsDefined(typeof(EnemyFaction), value) ? value : EnemyFaction.HollowKnights; } private readonly NetworkVariable health = new NetworkVariable(), maximum = new NetworkVariable(); private readonly NetworkVariable elite = new NetworkVariable(); private readonly NetworkVariable deathStarted = new NetworkVariable(); private readonly NetworkVariable spawnIdentity = new NetworkVariable(); private readonly NetworkVariable marked = new NetworkVariable(); private readonly NetworkVariable scanExposure = new NetworkVariable(); private readonly ScanWeakPointState scanPoints = new ScanWeakPointState(); private readonly NetworkVariable replicatedRootUntil = new NetworkVariable(), replicatedStunUntil = new NetworkVariable(); private readonly NetworkVariable replicatedGuard = new NetworkVariable(), replicatedSpeed = new NetworkVariable(1); private readonly NetworkVariable replicatedSlow = new NetworkVariable(); private readonly EnemyStatusState statuses = new EnemyStatusState(); private EnemyStatusSnapshot statusSnapshot; public EnemyStatusSnapshot DiagnosticStatus => statusSnapshot; public bool IsMarked => marked.Value; public bool IsScanExposed=>scanExposure.Value.Active(Lifetime,Now)&&IsAlive; public ScanWeakPointSnapshot ScanWeakPoints=>scanExposure.Value; public bool IsScanSlotActive(byte slot)=>IsSpawned&&IsAlive&&scanExposure.Value.SlotActive(slot,Lifetime,Now); public EnemyStatusSnapshot StatusSnapshot => IsServer?statuses.Evaluate(Now):new EnemyStatusSnapshot(replicatedSpeed.Value, Now active = new List(); private HealthState state; private NavMeshAgent agent; private double deathAt; private Collider[] colliders; private bool[] scanColliderFlags; private bool[] defaultColliderFlags; private Renderer[] renderers; private PrototypeEncounterDirector director; private bool pendingElite; private EnemyBehaviorHost behaviorHost; public bool BehaviorReady { get; private set; } public Transform HealthBarAnchor => healthBarAnchor; public event System.Action HealthChanged; public NavMeshAgent Navigation => agent; public static IReadOnlyList ActiveEnemies => active; public float CurrentHealth => health.Value; public float MaximumHealth => maximum.Value; public bool IsAlive => IsSpawned && CurrentHealth > 0; public bool IsHostile => true; public bool IsElite => elite.Value; public bool IsBoss => definition != null && definition.boss; public ulong TargetId => NetworkObjectId; public ulong SpawnIdentity => spawnIdentity.Value; public uint Lifetime => SpawnIdentity == 0 ? 0 : (uint)((SpawnIdentity - 1) % uint.MaxValue + 1); // Independent of NGO's recyclable live-object IDs; never reset between stages or sessions. public static ulong AllocateSpawnIdentity() => checked(++nextSpawnIdentity); public Vector3 AimPoint => healthBarAnchor != null ? healthBarAnchor.position : transform.position + Vector3.up * (IsBoss ? 1.8f : IsElite ? 1.2f : .9f); public EnemyResistanceValues Resistances { get; private set; } = EnemyResistanceValues.Normal; public float StageDamageMultiplier { get; private set; } = 1; public int AgentType => definition.ResolveAgentType(pendingElite || IsElite); private double Now => NetworkManager != null ? NetworkManager.ServerTime.Time : 0; private void Awake() { behaviorHost = GetComponent(); if(GetComponent()==null)gameObject.AddComponent(); if(GetComponent()==null)gameObject.AddComponent(); if(GetComponent()==null)gameObject.AddComponent(); agent = GetComponent(); if (agent != null) agent.enabled = false; colliders = GetComponentsInChildren(); renderers = GetComponentsInChildren(); scanColliderFlags=new bool[colliders.Length]; defaultColliderFlags=new bool[colliders.Length]; for(int i=0;i();scanColliderFlags[i]=hitbox!=null&&hitbox.isScanWeakPoint;defaultColliderFlags[i]=colliders[i].enabled;} } public void ConfigureSpawn(bool isElite, PrototypeEncounterDirector owner) { pendingElite = definition != null ? definition.ResolveElite(isElite) : isElite; director = owner; if (agent != null) agent.agentTypeID = AgentType; } public override void OnNetworkSpawn() { BehaviorReady = false; if (definition == null || !definition.Valid || agent == null) { enabled = false; Debug.LogError("Passive enemy requires valid entity data and a navigation agent.", this); return; } deathAt = 0; active.Add(this); health.OnValueChanged += OnHealth; elite.OnValueChanged += OnElite; deathStarted.OnValueChanged += OnDeathStarted; if (IsServer) { spawnIdentity.Value = AllocateSpawnIdentity(); arrivalSnapshot.Value = arrivalState.Bind(SpawnIdentity); faction.Value = (byte)pendingFaction; pendingElite = definition.ResolveElite(pendingElite); elite.Value = pendingElite; Resistances=definition.ResolveResistances(pendingElite); StageDamageMultiplier = PrototypeRunDirector.Instance != null ? PrototypeRunDirector.Instance.CurrentStageDamageMultiplier : 1; marked.Value=false;statuses.Reset(Lifetime,Resistances); scanPoints.Reset(Lifetime);scanExposure.Value=scanPoints.Snapshot; replicatedRootUntil.Value=replicatedStunUntil.Value=0;replicatedGuard.Value=replicatedSlow.Value=0;replicatedSpeed.Value=1; float hp = EnemyResistanceRules.ResolveBaseHealth(definition.maximumHealth,Resistances); int participants = PrototypeRunDirector.Instance != null ? PrototypeRunDirector.Instance.StageParticipantCount : NetworkManager.ConnectedClientsIds.Count; participants=Mathf.Max(1,participants); double participantScale=IsBoss ? 1 + .5f * (participants - 1) : 1 + .35f * (participants - 1); double stageScale=PrototypeRunDirector.Instance != null ? PrototypeRunDirector.Instance.CurrentStageHealthMultiplier : 1; hp=(float)System.Math.Min(float.MaxValue,(double)hp*participantScale*stageScale); state = new HealthState(hp); maximum.Value = hp; health.Value = hp; agent.agentTypeID = AgentType; if (IsBoss) { agent.radius = .9f; agent.height = 3.3f; } else if (EnemySpawnCircleRules.TryResolveClearance(definition, IsElite, out float radius, out float height, out _)) { agent.radius = radius; agent.height = height; } agent.baseOffset = 0; agent.updatePosition = true; agent.updateRotation = false; agent.speed = 0; agent.enabled = true; if (agent.isOnNavMesh) { agent.ResetPath(); agent.isStopped = true; agent.velocity = Vector3.zero; } } ApplySize(); ShowAlive(); GetComponent()?.ApplyFaction(); BehaviorReady = IsServer; if (IsServer && behaviorHost != null && !behaviorHost.InitializeServer(this)) Debug.LogWarning(behaviorHost.LastError, behaviorHost); } private void OnHealth(float previous, float current) { ShowAlive(); HealthChanged?.Invoke(previous, current); } private void OnDeathStarted(double previous,double current) { if(current>0) presentation?.Dissolve(current); } private void OnElite(bool previous, bool current) => ApplySize(); private void ApplySize() { float size = IsBoss ? 1.8f : IsElite ? 1.35f : 1; if (definition.role == EnemyRole.Melee && !IsBoss && definition.archetype == EnemyArchetype.Standard) size *= definition.meleeVisualScale; visuals.localScale = Vector3.one * size; if (hitboxes != null) hitboxes.localScale = Vector3.one * size; presentation?.SetElite(IsElite); var capsule = GetComponent(); if (capsule != null) { capsule.radius = .45f * size; capsule.height = (definition.role == EnemyRole.Melee && !IsBoss ? .9f : 1.4f) * size; capsule.center = Vector3.up * capsule.height * .5f; } if (capsule != null && definition.archetype != EnemyArchetype.Standard && EnemyRosterGeometry.TryResolve(definition.archetype, IsElite, out float radius, out float height, out _)) { capsule.radius = radius * .8f; capsule.height = Mathf.Max(capsule.radius * 2, height * .72f); capsule.center = Vector3.up * capsule.height * .5f; } } private void ShowAlive() { foreach(var renderer in renderers) renderer.enabled = true; for(int i=0;i 0&&(scanColliderFlags[i]?IsServer&&ScanColliderActive(colliders[i]):defaultColliderFlags[i]); if (CurrentHealth <= 0 && deathStarted.Value > 0) presentation?.Dissolve(deathStarted.Value); } private void Update() { if (!IsSpawned || !IsServer) return; RefreshStatusConstraintsServer(); if (!IsAlive && deathAt > 0 && Now >= deathAt) NetworkObject.Despawn(true); } public float DamageServer(float amount) => ApplyDamageServer(new DamageContext(ulong.MaxValue,DamageKind.Hazard,amount,false,AimPoint)).Applied; public float ResolveIncomingHitDamage(float amount)=>EnemyResistanceRules.ResolveDamage(amount, statuses.Evaluate(Now).Guard,Resistances); public DamageResult ApplyDamageServer(DamageContext context) { if(!IsServer||!IsAlive)return default; context=new DamageContext(context.SourceOwner,context.Kind,ResolveIncomingHitDamage(context.Amount),context.Critical,context.HitPoint,context.AttackId,context.Secondary,context.IsUltimate); float top=transform.position.y;foreach(var renderer in renderers)top=Mathf.Max(top,renderer.bounds.max.y); var result=DamageRules.Apply(state,context,TargetId,Lifetime,true,new Vector3(transform.position.x,top+.25f,transform.position.z)); if(result.Lethal){BehaviorReady=false;behaviorHost?.ShutdownServer(EnemyShutdownReason.Death);deathStarted.Value=Now;} health.Value=state.Current; PrototypeRunDirector.Instance?.RecordEnemyDamage(this,context,result); if(result.HasDamage&&!result.Lethal)HitReactionRpc(result.Critical); if(result.Lethal){ClearScanServer();deathAt=Now+1.1;director?.NotifyEnemyDeath(this);} return result; } public bool ApplyStatusServer(StatusApplication application,double now) { if(!IsServer||!IsAlive)return false; bool accepted=statuses.TryApply(Lifetime,application,now); if(accepted)RefreshStatusConstraintsServer(); return accepted; } public bool ApplyScanServer(StatusApplication application,double now) { if(!IsServer||!IsAlive)return false; var anchors=GetComponent(); bool fresh=!scanPoints.Snapshot.Active(Lifetime,now); byte[] selected=null; if(fresh&&(anchors==null||anchors.body==null||!scanPoints.CanBeginGeneration ||!anchors.TrySelect(unchecked((uint)(SpawnIdentity^application.CastId^application.Source)),out selected)))return false; if(!statuses.TryApplyScan(Lifetime,application,now))return false; double until=statuses.ScanExposureUntil(now); if(fresh&&!scanPoints.Begin(Lifetime,now,until,selected))return false; scanPoints.RefreshExpiry(until);scanExposure.Value=scanPoints.Snapshot; GetComponent()?.FollowBodyPose(); RefreshStatusConstraintsServer();return true; } public bool IsScanWeakPointActive(DamageHitbox hitbox,double now)=>IsServer&&IsAlive&&hitbox!=null&&hitbox.isScanWeakPoint &&RegisteredScanSlot(hitbox)&&scanExposure.Value.SlotActive(hitbox.scanSlot,Lifetime,now); private bool RegisteredScanSlot(DamageHitbox hitbox) { var view=GetComponent(); return hitbox!=null&&hitbox.scanSlot<3&&ReferenceEquals(hitbox.Receiver,this)&&view!=null&&view.spots!=null &&hitbox.scanSlot();return RegisteredScanSlot(box)&&IsScanSlotActive(box.scanSlot);} private void ClearScanServer() {scanPoints.Reset(Lifetime);if(IsServer)scanExposure.Value=scanPoints.Snapshot;} public bool TryConsumeScanWeakPointServer(byte slot,uint generation,uint lifetime,double now) { if(!IsServer||!IsAlive||!scanPoints.TryConsume(slot,generation,lifetime,now))return false; scanExposure.Value=scanPoints.Snapshot; var view=GetComponent(); if(view!=null&&view.spots!=null&&slot();if(collider!=null)collider.enabled=false;} return true; } public void RemoveStatusDurationBonusServer(ulong owner) {if(IsServer&&IsAlive){statuses.RemoveDurationBonus(owner);RefreshStatusConstraintsServer();}} public void RemoveAbilityStatusSourceServer(ulong owner,int abilityId) {if(IsServer&&IsAlive){statuses.RemoveAbilitySource(owner,abilityId);RefreshStatusConstraintsServer();}} private void RefreshStatusConstraintsServer() {statusSnapshot=statuses.Evaluate(Now);marked.Value=statusSnapshot.Marked; scanPoints.RefreshExpiry(statuses.ScanExposureUntil(Now));scanExposure.Value=scanPoints.Snapshot; replicatedRootUntil.Value=statuses.RootUntil;replicatedStunUntil.Value=statuses.StunUntil; replicatedGuard.Value=statusSnapshot.Guard;replicatedSpeed.Value=statusSnapshot.SpeedMultiplier; replicatedSlow.Value=statusSnapshot.SlowFraction; for(int i=0;i()!=this)length=Mathf.Min(length,Mathf.Max(0,displacementHits[i].distance-.02f)); // Search the longest supported, connected, collision-safe fraction of the admitted request. float low=0,high=length;Vector3 best=from; for(int attempt=0;attempt<10&&high-low>.001f;attempt++) { float distance=attempt==0?high:(low+high)*.5f; bool safe=NavMesh.SamplePosition(from+direction*distance,out var support,.25f,filter) &&Mathf.Abs(support.position.y-from.y)<=.25f&&EnemyResistanceRules.WithinDisplacementBudget(from,support.position,distance) &&Vector3.Dot(support.position-from,direction)>0&&!NavMesh.Raycast(from,support.position,out _,filter); if(safe){low=distance;best=support.position;if(attempt==0)break;}else high=distance; } if((best-from).sqrMagnitude<.000001f)return false; agent.Move(best-from);Vector3 actual=agent.nextPosition; if(!EnemyResistanceRules.WithinDisplacementBudget(from,actual,length)){agent.nextPosition=from;return false;} transform.position=actual;return true; } private static bool Finite(Vector3 value) => FiniteVector(value); private static bool FiniteVector(Vector3 value)=>!float.IsNaN(value.x)&&!float.IsInfinity(value.x) &&!float.IsNaN(value.y)&&!float.IsInfinity(value.y)&&!float.IsNaN(value.z)&&!float.IsInfinity(value.z); public void CancelHostileEffectsServer(EnemyShutdownReason reason = EnemyShutdownReason.StageEnd) { if (IsServer) { BehaviorReady=false;behaviorHost?.ShutdownServer(reason);ClearScanServer(); } } public bool WarpNavigationServer(Vector3 position) { if (!IsServer || !IsSpawned || agent == null || !agent.enabled || !agent.isOnNavMesh || !FiniteVector(position) || !agent.Warp(position)) return false; transform.position = agent.nextPosition; return true; } [Rpc(SendTo.ClientsAndHost,InvokePermission=RpcInvokePermission.Server)] private void HitReactionRpc(bool headshot) => presentation?.Hit(headshot); public override void OnNetworkDespawn() { if (IsServer) director?.NotifyDirectorDespawn(this); arrivalState.ClearForNextBirth(); DirectorLedger = null; DirectorAdmissionId = 0; DirectorMemberSlot = 0; DirectorSourcePrefab = null; BehaviorReady=false;behaviorHost?.ShutdownServer(EnemyShutdownReason.Despawn); ClearScanServer(); active.Remove(this); health.OnValueChanged -= OnHealth; elite.OnValueChanged -= OnElite; deathStarted.OnValueChanged -= OnDeathStarted; if (agent != null) agent.enabled = false; presentation?.Clear(); } private void OnDisable(){BehaviorReady=false;behaviorHost?.ShutdownServer(EnemyShutdownReason.Disabled);} public override void OnDestroy(){BehaviorReady=false;behaviorHost?.ShutdownServer(EnemyShutdownReason.Despawn);active.Remove(this);base.OnDestroy();} } }