| // Adapted from Game AI Pro - Crytek’s Target Tracks Perception System | | | using System.Collections.Generic; | | | using UnityEngine; | | | namespace Perception { | | | public class PerceptionAgent : MonoBehaviour { | | | #region Fields | |
| [Header("Sight")] | |
| [SerializeField] float viewRange = 15f; | |
| [SerializeField] float primaryFov = 90f; | |
| [SerializeField] float peripheralFov = 160f; | |
| [SerializeField] LayerMask obstructionMask = ~0; | |
| [Header("Awareness")] | |
| [SerializeField] float alertThreshold = 25f; | |
| [SerializeField] float highAlertEnter = 50f; | |
| [SerializeField] float highAlertExit = 35f; | |
| [SerializeField] float turnSpeed = 4f; | |
| readonly Dictionary<Transform, TargetTrack> tracks = new(); | |
| readonly Dictionary<Transform, HashSet<StimType>> active = new(); | |
| static readonly StimType[] allTypes = { StimType.VisualPrimary, StimType.VisualPeripheral, StimType.AudioMovement, StimType.AudioLoud }; | | | bool latchedHighAlert; | | | Renderer rend; | | | Material mat; | |
| static readonly Color idle = new(0.2f, 0.8f, 0.3f), | |
| suspicious = new(1f, 0.85f, 0.1f), | |
| alert = new(1f, 0.2f, 0.15f); | |
| #endregion | |
| void LateUpdate() { | |
| ScanSight(); | |
| TickTracks(); | |
| React(); | |
| active.Clear(); | |
| } | |
| void React() { | |
| TargetTrack best = null; | |
| Transform bestTarget = null; | |
| foreach (var pair in tracks) { | |
| if (best == null || pair.Value.Score > best.Score) { | |
| best = pair.Value; | |
| bestTarget = pair.Key; | |
| } | | | } | | | var score = best != null ? best.Score : 0f; | | | var isPerceiving = bestTarget && active.TryGetValue(bestTarget, out var set) && set.Count > 0; | | | if (best != null && score >= alertThreshold && isPerceiving) { | | | var dir = best.LastKnownPosition - transform.position; | |
| dir.y = 0f; | |
| if (dir.sqrMagnitude > 0.01f) transform.rotation = Quaternion.Slerp(transform.rotation, Quaternion.LookRotation(dir), turnSpeed * Time.deltaTime); | |
| } | |
| if (score >= highAlertEnter) latchedHighAlert = true; | |
| else if (score < highAlertExit || score <= 0f) latchedHighAlert = false; | |
| if (mat) mat.color = latchedHighAlert ? alert : score >= alertThreshold ? suspicious : idle; | |
| } | |
| void TickTracks() { | |
| var remove = new List<Transform>(); | |
| foreach (var pair in tracks) { | |
| foreach (var t in allTypes) | |
| pair.Value.Tick(t, active.TryGetValue(pair.Key, out var set) && set.Contains(t), Time.deltaTime); | |
| if (pair.Value.Score <= 0f) remove.Add(pair.Key); | |
| } | | | foreach (var t in remove) tracks.Remove(t); | | | } | |
| void ScanSight() { | |
| var player = GameObject.FindGameObjectWithTag("Player"); | |
| if (!player) return; | |
| var target = player.transform; | |
| var eye = transform.position + Vector3.up; | |
| var to = target.position + Vector3.up - eye; | |
| var dist = to.magnitude; | |
| if (dist > viewRange) return; | |
| var angle = Vector3.Angle(transform.forward, to); | |
| StimType? type = null; | |
| if (angle <= primaryFov * 0.5f) type = StimType.VisualPrimary; | |
| else if (angle <= peripheralFov * 0.5f) type = StimType.VisualPeripheral; | |
| if (type == null || !HasLineOfSight(eye, to.normalized, dist, target)) return; | |
| PerceptionHub.Emit(new Stim(type.Value, target, target.position, viewRange), transform); | |
| } | | | bool HasLineOfSight(Vector3 origin, Vector3 direction, float distance, Transform target) { | |
| if (!Physics.Raycast(origin, direction, out var hit, distance, obstructionMask)) return true; | |
| return hit.transform == target || hit.transform.IsChildOf(target); | |
| } | |
| public void Receive(Stim stim, Transform observer = null) { | |
| if (stim.Type == StimType.VisualPrimary || stim.Type == StimType.VisualPeripheral) { | |
| if (observer != transform) return; | |
| } | |
| if (!stim.Source || Vector3.Distance(transform.position, stim.Position) > RangeFor(stim)) return; | |
| if (!tracks.TryGetValue(stim.Source, out var track)) { | |
| track = new TargetTrack { Target = stim.Source }; | |
| tracks[stim.Source] = track; | |
| } | | | var config = ConfigFor(stim.Type); | | | track.Feed(stim.Type, config.peak, config.attack, config.release, stim.Position); | |
| if (!active.TryGetValue(stim.Source, out var set)) { | |
| set = new HashSet<StimType>(); | |
| active[stim.Source] = set; | |
| } | | | set.Add(stim.Type); | | | } | |
| float RangeFor(Stim s) => s.Type == StimType.AudioMovement || s.Type == StimType.AudioLoud ? s.Radius : viewRange; | |
| (float peak, float attack, float release) ConfigFor(StimType t) { | |
| return t switch { | |
| StimType.VisualPrimary => (100f, 2f, 40f), | |
| StimType.VisualPeripheral => (40f, 4f, 40f), | |
| StimType.AudioMovement => (25f, 1f, 8f), | |
| _ => (80f, 0.5f, 18f) | |
| }; | |
| } | |
| void Awake() { | |
| rend = GetComponent<Renderer>(); | |
| if (rend) mat = rend.material; | |
| } | |
| void OnEnable() => PerceptionHub.Register(this); | |
| void OnDisable() => PerceptionHub.Unregister(this); | |
| } | | | } |
| using System.Collections.Generic; | | | using UnityEngine; | | | using UnityEngine.Rendering; | | | namespace Perception { | | | public class PerceptionStimVisualizer : MonoBehaviour { | | | struct Pulse { | | | public StimType Type; | | | public Transform Source; | | | public Vector3 Position; | | | public float Radius; | | | public Transform Observer; | | | public float StartTime; | | | public float EndTime; | | | public int RingIndex; | | | public int BeamIndex; | | | public int BracketIndex; | |
| public bool Alive(float time) => time < EndTime; | |
| public void Refresh(Vector3 pos, float linger) { | |
| Position = pos; | |
| EndTime = Time.time + linger; | |
| } | | | } | |
| [Header("Timing")] | |
| [SerializeField] float audioLinger = 0.4f; | |
| [SerializeField] float visualLinger = 0.3f; | |
| [SerializeField] float expandDuration = 0.5f; | |
| [Header("Ring")] | |
| [SerializeField] int ringSegments = 48; | |
| [SerializeField] float ringWidth = 0.08f; | |
| [SerializeField] float loudRingWidth = 0.14f; | |
| [Header("Sight Beam")] | |
| [SerializeField] float beamWidth = 0.06f; | |
| [SerializeField] float bracketSize = 0.55f; | |
| [SerializeField] float bracketHeight = 1.8f; | |
| readonly List<Pulse> pulses = new(); | |
| readonly List<LineRenderer> rings = new(); | |
| readonly List<LineRenderer> beams = new(); | |
| readonly List<LineRenderer> brackets = new(); | |
| static readonly Color primaryColor = new(0.35f, 1f, 0.55f, 1f); | |
| static readonly Color peripheralColor = new(1f, 0.9f, 0.3f, 1f); | |
| static readonly Color walkColor = new(0.35f, 0.85f, 1f, 1f); | |
| static readonly Color sprintColor = new(1f, 0.55f, 0.2f, 1f); | |
| void OnEnable() => PerceptionHub.StimEmitted += OnStim; | |
| void OnDisable() => PerceptionHub.StimEmitted -= OnStim; | |
| void Awake() { | |
| EnsurePool(rings, 16, "Ring"); | |
| EnsurePool(beams, 8, "Beam"); | |
| EnsurePool(brackets, 8, "Bracket"); | |
| } | |
| void OnStim(Stim stim, Transform observer) { | |
| var linger = IsAudio(stim.Type) ? audioLinger : visualLinger; | |
| for (var i = 0; i < pulses.Count; i++) { | |
| var p = pulses[i]; | |
| if (p.Type != stim.Type) continue; | |
| if (IsAudio(stim.Type) && p.Source == stim.Source) { | |
| p.Refresh(stim.Position, linger); | |
| pulses[i] = p; | |
| return; | | | } | |
| if (!IsAudio(stim.Type) && p.Observer == observer) { | |
| p.Refresh(stim.Position, linger); | |
| pulses[i] = p; | |
| return; | | | } | | | } | | | pulses.Add(new Pulse { | | | Type = stim.Type, | | | Source = stim.Source, | | | Position = stim.Position, | | | Radius = stim.Radius, | | | Observer = observer, | | | StartTime = Time.time, | | | EndTime = Time.time + linger, | |
| RingIndex = -1, | |
| BeamIndex = -1, | |
| BracketIndex = -1 | |
| }); | |
| } | |
| void LateUpdate() { | |
| var time = Time.time; | |
| HidePool(rings); | |
| HidePool(beams); | |
| HidePool(brackets); | |
| for (var i = pulses.Count - 1; i >= 0; i--) { | |
| if (!pulses[i].Alive(time)) { | |
| pulses.RemoveAt(i); | |
| continue; | | | } | | | pulses[i] = DrawPulse(pulses[i], time); | | | } | | | } | |
| Pulse DrawPulse(Pulse pulse, float time) { | |
| var linger = IsAudio(pulse.Type) ? audioLinger : visualLinger; | |
| var fade = Mathf.Clamp01((pulse.EndTime - time) / linger); | |
| var expand = Mathf.Clamp01((time - pulse.StartTime) / expandDuration); | |
| var color = ColorFor(pulse.Type); | |
| color.a *= fade; | |
| var ground = new Vector3(pulse.Position.x, pulse.Position.y + 0.05f, pulse.Position.z); | | | if (IsAudio(pulse.Type)) { | | | pulse.RingIndex = DrawRing(pulse.RingIndex, ground, pulse.Radius * expand, color, pulse.Type == StimType.AudioLoud ? loudRingWidth : ringWidth); | |
| if (pulse.Type == StimType.AudioLoud && expand > 0.15f) { | |
| var echo = ColorFor(pulse.Type); | |
| echo.a = fade * 0.4f; | |
| DrawRing(-1, ground, pulse.Radius * expand * 0.5f, echo, ringWidth * 0.55f); | | | } | | | return pulse; | | | } | | | if (pulse.Observer) { | | | var eye = pulse.Observer.position + Vector3.up; | | | var target = pulse.Position + Vector3.up; | | | pulse.BeamIndex = DrawBeam(pulse.BeamIndex, eye, target, color, beamWidth); | | | } | | | var bracketCenter = pulse.Position + Vector3.up * 0.5f; | | | pulse.BracketIndex = DrawBracket(pulse.BracketIndex, bracketCenter, bracketSize, bracketHeight, color); | | | DrawRing(-1, ground, 0.5f + expand * 0.5f, color, ringWidth * 0.65f); | | | return pulse; | | | } | | | int DrawRing(int index, Vector3 center, float radius, Color color, float width) { | |
| if (radius <= 0.01f) return index; | |
| var lr = Acquire(rings, ref index); | |
| lr.enabled = true; | |
| ApplyLine(lr, color, width); | |
| lr.positionCount = ringSegments + 1; | |
| lr.loop = true; | |
| for (var i = 0; i <= ringSegments; i++) { | |
| var a = i / (float)ringSegments * Mathf.PI * 2f; | |
| lr.SetPosition(i, center + new Vector3(Mathf.Cos(a) * radius, 0f, Mathf.Sin(a) * radius)); | |
| } | | | return index; | | | } | | | int DrawBeam(int index, Vector3 from, Vector3 to, Color color, float width) { | |
| var lr = Acquire(beams, ref index); | |
| lr.enabled = true; | |
| ApplyLine(lr, color, width); | |
| lr.loop = false; | |
| lr.positionCount = 2; | |
| lr.SetPosition(0, from); | |
| lr.SetPosition(1, to); | |
| return index; | | | } | | | int DrawBracket(int index, Vector3 center, float size, float height, Color color) { | |
| var lr = Acquire(brackets, ref index); | |
| lr.enabled = true; | |
| ApplyLine(lr, color, ringWidth); | |
| lr.loop = false; | |
| lr.positionCount = 8; | |
| var h = height * 0.5f; | |
| var s = size * 0.5f; | |
| lr.SetPosition(0, center + new Vector3(-s, -h, 0f)); | |
| lr.SetPosition(1, center + new Vector3(-s, h, 0f)); | |
| lr.SetPosition(2, center + new Vector3(-s * 0.4f, h, 0f)); | |
| lr.SetPosition(3, center + new Vector3(0f, h + s * 0.35f, 0f)); | |
| lr.SetPosition(4, center + new Vector3(s * 0.4f, h, 0f)); | |
| lr.SetPosition(5, center + new Vector3(s, h, 0f)); | |
| lr.SetPosition(6, center + new Vector3(s, -h, 0f)); | |
| lr.SetPosition(7, center + new Vector3(-s, -h, 0f)); | |
| return index; | | | } | |
| static bool IsAudio(StimType t) => t == StimType.AudioMovement || t == StimType.AudioLoud; | |
| static Color ColorFor(StimType t) { | |
| if (t == StimType.VisualPrimary) return primaryColor; | |
| if (t == StimType.VisualPeripheral) return peripheralColor; | |
| if (t == StimType.AudioLoud) return sprintColor; | |
| return walkColor; | | | } | |
| void EnsurePool(List<LineRenderer> pool, int count, string label) { | |
| for (var i = pool.Count; i < count; i++) { | |
| var go = new GameObject($"{label}_{i}"); | |
| go.transform.SetParent(transform, false); | |
| var lr = go.AddComponent<LineRenderer>(); | |
| lr.useWorldSpace = true; | |
| lr.shadowCastingMode = ShadowCastingMode.Off; | |
| lr.receiveShadows = false; | |
| lr.material = new Material(Shader.Find("Sprites/Default")); | |
| lr.enabled = false; | |
| pool.Add(lr); | |
| } | | | } | |
| static void HidePool(List<LineRenderer> pool) { | |
| foreach (var lr in pool) lr.enabled = false; | |
| } | |
| static LineRenderer Acquire(List<LineRenderer> pool, ref int index) { | |
| if (index < 0 || index >= pool.Count || pool[index].enabled) { | |
| index = -1; | |
| for (var i = 0; i < pool.Count; i++) { | |
| if (!pool[i].enabled) { index = i; break; } | |
| } | | | if (index < 0) index = 0; | | | } | | | return pool[index]; | | | } | | | static void ApplyLine(LineRenderer lr, Color color, float width) { | |
| lr.startColor = lr.endColor = color; | |
| lr.startWidth = lr.endWidth = width; | |
| } | | | } | | | } |
| using AdvancedController; // see https://www.youtube.com/watch?v=jSauntZrQro | | | using UnityEngine; | | | namespace Perception { | | | public class StimEmitter : MonoBehaviour { | |
| [SerializeField] float walkSpeed = 1.5f; | |
| [SerializeField] float sprintSpeed = 4f; | |
| [SerializeField] float walkRadius = 8f; | |
| [SerializeField] float sprintRadius = 18f; | |
| PlayerController player; | | | Transform body; | |
| void Awake() { | |
| player = GetComponent<PlayerController>(); | |
| body = transform; | |
| } | |
| void Update() { | |
| if (!player || !body) return; | |
| var vel = player.GetMovementVelocity(); | |
| var speed = vel.magnitude; | |
| if (speed < walkSpeed) return; | |
| var loud = speed >= sprintSpeed; | |
| PerceptionHub.Emit( | | | new Stim( | | | loud ? StimType.AudioLoud : StimType.AudioMovement, | | | body, | | | body.position, | |
| loud ? sprintRadius : walkRadius) | |
| ); | |
| } | | | } | | | } |
| using System; | | | using System.Collections.Generic; | | | using UnityEngine; | | | namespace Perception { | | | public enum StimType { VisualPrimary, VisualPeripheral, AudioMovement, AudioLoud } | | | public static class PerceptionHub { | | | static readonly List<PerceptionAgent> agents = new(); | | | public static event Action<Stim, Transform> StimEmitted; | |
| public static void Register(PerceptionAgent agent) => agents.Add(agent); | |
| public static void Unregister(PerceptionAgent agent) => agents.Remove(agent); | |
| public static void Emit(Stim stim, Transform observer = null) { | |
| StimEmitted?.Invoke(stim, observer); | |
| foreach (var a in agents) a.Receive(stim, observer); | |
| } | | | } | | | public class TargetTrack { | | | public Transform Target; | | | public Vector3 LastKnownPosition; | | | readonly Dictionary<StimType, Envelope> envelopes = new(); | | | public float Score { | | | get { | | | var max = 0f; | | | foreach (var e in envelopes.Values) | | | if (e.Value > max) max = e.Value; | | | return max; | | | } | | | } | | | public void Feed(StimType type, float peak, float attack, float release, Vector3 pos) { | |
| LastKnownPosition = pos; | |
| if (!envelopes.TryGetValue(type, out var env)) { | |
| env = new Envelope(); | |
| envelopes[type] = env; | |
| } | | | env.Configure(peak, attack, release); | | | } | | | public void Tick(StimType type, bool stimulated, float dt) { | | | if (envelopes.TryGetValue(type, out var env)) env.Tick(stimulated, dt); | | | } | | | } | | | public class Envelope { | | | float value, peak, attackRate, releaseRate; | | | public float Value => value; | | | public void Configure(float p, float attack, float release) { | |
| peak = p; | |
| attackRate = p / Mathf.Max(attack, 0.01f); | |
| releaseRate = p / Mathf.Max(release, 0.01f); | |
| } | |
| public void Tick(bool stimulated, float dt) { | |
| if (stimulated) value = Mathf.Min(peak, value + attackRate * dt); | |
| else value = Mathf.Max(0f, value - releaseRate * dt); | |
| } | | | } | | | public struct Stim { | |
| public StimType Type { get; } | |
| public Transform Source { get; } | |
| public Vector3 Position { get; } | |
| public float Radius { get; } | |
| public Stim(StimType type, Transform source, Vector3 position, float radius) { | |
| Type = type; | |
| Source = source; | |
| Position = position; | |
| Radius = radius; | |
| } | | | } | | | } |