using System; using System.Collections.Generic; using ProjectI.Combat; using UnityEngine; namespace ProjectI.Enemies { [DisallowMultipleComponent] public sealed class EnemyPerception : MonoBehaviour, IEnemyCapability { public EnemyPerceptionDefinition definition; public Transform eye; private EnemyActorContext actor; private sealed class Entry { internal PrototypePlayerCombat Player; internal EnemyObservationRules Memory; } private readonly Dictionary entries = new Dictionary(); private readonly List removed = new List(); private readonly List memories = new List(); private readonly RaycastHit[] hits = new RaycastHit[32]; private readonly Collider[] overlaps = new Collider[16]; private EnemyTargetSelection selection; private double interval, reactionDelay, memorySeconds, nextSample, sampledAt; private float sightRange, fieldOfView, hearingRange; private Vector3 sampleEyePosition, sampleForward; internal Vector3 LocalEye => transform.InverseTransformPoint(EyePosition); internal Quaternion LocalEyeRotation => eye != null ? Quaternion.Inverse(transform.rotation) * eye.rotation : Quaternion.identity; internal float SightRange => sightRange; internal float FieldOfView => fieldOfView; #if UNITY_EDITOR || DEVELOPMENT_BUILD public string SelectionReason => selection != null ? selection.Reason : "Inactive"; public string LastInvalidation { get; private set; } = ""; #endif public double HoldUntil => selection != null ? selection.HoldUntil : 0; public EnemyActorContext Actor => actor; private Vector3 EyePosition => eye != null ? eye.position : transform.position + Vector3.up * 1.6f; public void InitializeServer(EnemyActorContext context) { ShutdownServer(EnemyShutdownReason.InitializationFailure); if (context == null || !context.IsValid || context.Owner.gameObject != gameObject || definition == null || !definition.Valid || eye != null && !eye.IsChildOf(transform)) throw new InvalidOperationException("Perception requires valid local actor, eye and finite definition."); actor = context; interval = definition.perceptionInterval; reactionDelay = definition.visualReactionDelay; memorySeconds = definition.investigationSeconds; sightRange = definition.sightRange; fieldOfView = definition.fieldOfView; hearingRange = definition.hearingRange; selection = new EnemyTargetSelection(definition.targetHoldSeconds); sampledAt = double.NaN; nextSample = context.Now + interval * (context.NetworkObjectId % 7) / 7d; #if UNITY_EDITOR || DEVELOPMENT_BUILD LastInvalidation = ""; #endif EnemyCombatNoiseEvents.AcceptedWeaponShot += OnCombatNoise; } public void ShutdownServer(EnemyShutdownReason reason) { EnemyCombatNoiseEvents.AcceptedWeaponShot -= OnCombatNoise; actor = null; entries.Clear(); memories.Clear(); removed.Clear(); #if UNITY_EDITOR || DEVELOPMENT_BUILD selection?.Clear("Shutdown: " + reason); #else selection?.Clear(); #endif sampledAt = double.NaN; } private bool Ready(double now) { if (actor == null) return false; if (!actor.IsValid) { ShutdownServer(EnemyShutdownReason.SessionEnd); return false; } return isActiveAndEnabled && EnemyObservationRules.Finite(now) && now >= 0 && now <= actor.Now; } private bool Eligible(PrototypePlayerCombat player, EnemyTargetKey key) => actor != null && EnemyTargetRules.Eligible(player, key, actor.Network, actor.Stage, actor.RunId, actor.StageId); private Entry GetOrAdd(PrototypePlayerCombat player) { var key = new EnemyTargetKey(player.NetworkObjectId, player.SpawnLifetime); if (!entries.TryGetValue(key, out var entry)) { entry = new Entry { Player = player, Memory = new EnemyObservationRules(key, reactionDelay, memorySeconds, interval * 2.1) }; entries.Add(key, entry); } return entry; } private void ValidateMemories(double now) { removed.Clear(); memories.Clear(); foreach (var pair in entries) { if (!Eligible(pair.Value.Player, pair.Key)) { removed.Add(pair.Key); #if UNITY_EDITOR || DEVELOPMENT_BUILD LastInvalidation = "Ineligible target " + pair.Key; #endif } else { pair.Value.Memory.TryBest(now, out _, out _); memories.Add(pair.Value.Memory); } } foreach (var key in removed) entries.Remove(key); } public void SampleServer(double now) { if (!Ready(now)) return; ValidateMemories(now); if (now < nextSample) return; nextSample = now + interval; sampledAt = now; Vector3 origin = EyePosition; sampleEyePosition = origin; sampleForward = eye != null ? eye.forward : transform.forward; foreach (var client in actor.Network.ConnectedClientsList) { var playerObject = client.PlayerObject; if (playerObject == null || !playerObject.TryGetComponent(out var player)) continue; var key = new EnemyTargetKey(player.NetworkObjectId, player.SpawnLifetime); if (!Eligible(player, key)) continue; var memory = GetOrAdd(player).Memory; Vector3 feet = player.Vitals.AcceptedPosition; Vector3 direction = feet + Vector3.up * EnemyTargetingRules.TorsoHeight - origin; bool inView = direction.sqrMagnitude <= sightRange * sightRange && (fieldOfView >= 360 || Vector3.Angle(eye != null ? eye.forward : transform.forward, direction) <= fieldOfView * .5f); if (inView && EnemyTargetingRules.TrySelectPoint(origin, feet, transform, player.transform, true, 0, hits, overlaps, out var point)) memory.ObservePersonal(new EnemyObservation(key, point, now, actor.Owner.Identity, actor.SpawnIdentity, EnemyObservationKind.PersonalVisual), feet, origin, now); else memory.Miss(now); } ValidateMemories(now); selection.Select(memories, origin, now, out _); } public bool TryGetPersonalObservation(EnemyTargetKey target, double now, out EnemyObservation observation) { observation = default; if (!Ready(now)) return false; ValidateMemories(now); return entries.TryGetValue(target, out var entry) && entry.Memory.TryPersonal(now, out observation); } // Release-time verification, without changing sampled memory, selection or reaction clocks. public bool TryVerifyPersonalSightServer(EnemyTargetKey target, double now, out EnemyObservation observation) { observation = default; if (!TryGetSnapshot(target, now, out var snapshot) || !snapshot.VisibleAtLastSample || !snapshot.ReactionReady || !snapshot.Personal.HasValue || now >= snapshot.PersonalExpiresAt) return false; var player = entries[target].Player; Vector3 origin = EyePosition, feet = player.Vitals.AcceptedPosition; Vector3 direction = feet + Vector3.up * EnemyTargetingRules.TorsoHeight - origin; if (direction.sqrMagnitude > sightRange * sightRange || fieldOfView < 360 && Vector3.Angle(eye != null ? eye.forward : transform.forward, direction) > fieldOfView * .5f || !EnemyTargetingRules.TrySelectPoint(origin, feet, transform, player.transform, true, 0, hits, overlaps, out var point)) return false; observation = new EnemyObservation(target, point, now, actor.Owner.Identity, actor.SpawnIdentity, EnemyObservationKind.PersonalVisual); return true; } public bool TryGetSelectedTarget(double now, out EnemyTargetKey target) { target = default; if (!Ready(now)) return false; ValidateMemories(now); return selection.Select(memories, EyePosition, now, out target); } public bool TryVerifyPersonalFiringSightServer(EnemyTargetKey target, double now, Vector3 muzzle, float radius, float range, out EnemyObservation observation) { observation = default; if (!TryVerifyPersonalSightServer(target, now, out _)) return false; var player = entries[target].Player; if (!EnemyTargetingRules.TrySelectFiringPoint(EyePosition, muzzle, player.Vitals.AcceptedPosition, transform, player.transform, radius, range, hits, overlaps, out var point)) return false; observation = new EnemyObservation(target, point, now, actor.Owner.Identity, actor.SpawnIdentity, EnemyObservationKind.PersonalVisual); return true; } public bool TryGetSnapshot(EnemyTargetKey target, double now, out EnemyPerceptionSnapshot snapshot) { snapshot = default; if (!Ready(now)) return false; ValidateMemories(now); if (!entries.TryGetValue(target, out var entry)) return false; snapshot = new EnemyPerceptionSnapshot(entry.Memory, sampledAt, sampleEyePosition, sampleForward); return true; } #if UNITY_EDITOR || DEVELOPMENT_BUILD // Historical inspection only. Gameplay consumers use the validated query methods. public EnemyTargetKey? SelectedTargetAtLastDecision => selection?.Selected; public void CopyInspectionSnapshots(List destination) { destination.Clear(); foreach (var entry in entries.Values) destination.Add(new EnemyPerceptionSnapshot(entry.Memory, sampledAt, sampleEyePosition, sampleForward)); } #endif public bool ImportReportServer(EnemyActorContext source, EnemyObservation personal, double now) { if (!Ready(now) || source == null || !source.IsValid || source == actor || source.Network != actor.Network || source.Stage != actor.Stage || source.StageDefinition != actor.StageDefinition || source.RunId != actor.RunId || source.StageId != actor.StageId || personal.Kind != EnemyObservationKind.PersonalVisual || !personal.Source.Equals(source.Owner.Identity) || personal.SourceSpawnIdentity != source.SpawnIdentity || !actor.Network.SpawnManager.SpawnedObjects.TryGetValue(personal.Target.NetworkObjectId, out var obj) || !obj.TryGetComponent(out var player) || !Eligible(player, personal.Target)) return false; ValidateMemories(now); return GetOrAdd(player).Memory.Observe(new EnemyObservation(personal.Target, personal.Position, personal.ObservedAt, personal.Source, personal.SourceSpawnIdentity, EnemyObservationKind.SquadVisualReport), now); } private void OnCombatNoise(EnemyCombatNoise noise) => ReceiveCombatNoiseServer(noise); public bool ReceiveCombatNoiseServer(EnemyCombatNoise noise) { double now = actor != null ? actor.Now : 0; if (!Ready(now) || noise.Network != actor.Network || noise.Stage != actor.Stage || noise.StageDefinition != actor.StageDefinition || noise.RunId != actor.RunId || noise.StageId != actor.StageId || noise.Observation.Kind != EnemyObservationKind.HeardRegion || (noise.Observation.Position - EyePosition).sqrMagnitude > hearingRange * hearingRange || !actor.Network.SpawnManager.SpawnedObjects.TryGetValue(noise.Observation.Target.NetworkObjectId, out var obj) || !obj.TryGetComponent(out var player) || !Eligible(player, noise.Observation.Target)) return false; ValidateMemories(now); return GetOrAdd(player).Memory.Observe(noise.Observation, now); } private void Update() { if (actor != null) SampleServer(actor.Now); } private void OnDisable() => ShutdownServer(EnemyShutdownReason.Disabled); private void OnDestroy() => ShutdownServer(EnemyShutdownReason.Despawn); } }