using System.Collections.Generic; using System.Text; using ProjectI.Enemies; using UnityEditor; using UnityEditor.UIElements; using UnityEngine; using UnityEngine.UIElements; namespace ProjectI.Editor { // Opening this Editor-only window is the opt-in; it never drives gameplay. public sealed class EnemyPerceptionWindow : EditorWindow { [SerializeField] private EnemyPerception perception; private Label facts; [SerializeField] private bool markers; private ObjectField observerField; private Toggle markerToggle; private readonly List snapshots = new List(); public enum MarkerKind { SampledSight, RememberedSight, ExpiredSight, Report, Hearing, Destination, Hypothesis, CheckedSample, UnknownViewpoint, CommittedViewpoint } public readonly struct SceneMarker { public readonly MarkerKind Kind; public readonly Vector3 Position; public readonly string Label; public readonly float Radius; public SceneMarker(MarkerKind kind, Vector3 position, string label, float radius = .3f) { Kind = kind; Position = position; Label = label; Radius = radius; } } public readonly struct SceneRay { public readonly int Index; public readonly EnemyInvestigationEpisode.CoverRaySample Sample; public SceneRay(int index, EnemyInvestigationEpisode.CoverRaySample sample) { Index = index; Sample = sample; } } private bool HasActiveObserver => perception != null && perception.Actor != null && perception.Actor.IsValid; [MenuItem("ProjectI/Debug/Enemy AI/Observation view")] public static void Open() => GetWindow("Enemy observations"); public static EnemyPerceptionWindow Inspect(EnemyPerception observer) { var window = GetWindow("Enemy observations"); window.perception = observer; window.markers = true; window.observerField?.SetValueWithoutNotify(observer); window.markerToggle?.SetValueWithoutNotify(true); window.Refresh(); SceneView.RepaintAll(); return window; } private void OnEnable() { SceneView.duringSceneGui -= DrawMarkers; SceneView.duringSceneGui += DrawMarkers; } private void OnDisable() { SceneView.duringSceneGui -= DrawMarkers; SceneView.RepaintAll(); } public void CreateGUI() { rootVisualElement.Add(new Label("Scene view diagnostics · sampled sight is not firing permission.") { style = { whiteSpace = WhiteSpace.Normal } }); var field = observerField = new ObjectField("Observer") { objectType = typeof(EnemyPerception), allowSceneObjects = true, value = perception }; field.RegisterValueChangedCallback(evt => perception = evt.newValue as EnemyPerception); rootVisualElement.Add(field); rootVisualElement.Add(new Button(() => field.value = Selection.activeGameObject != null ? Selection.activeGameObject.GetComponent() : null) { text = "Use selected enemy" }); var toggle = markerToggle = new Toggle("Show Scene view markers (opt-in)") { value = markers }; toggle.RegisterValueChangedCallback(evt => { markers = evt.newValue; SceneView.RepaintAll(); }); rootVisualElement.Add(toggle); rootVisualElement.Add(new Label("Sphere: observed sight/history. Ring/path: committed movement. Diamond: assumed hiding sample. Labels identify wall-hit or normal search prediction sources. Square: checked sample, player not found during inspection. Samples do not clear the whole area. X: failed viewpoint, space unknown. Cross: report. Large box: hearing.") { style = { whiteSpace = WhiteSpace.Normal } }); rootVisualElement.Add(new Label("Cone ray history from the captured eye: solid + cross = wall hit; solid + box = other world surface; dotted + ring = no world hit; dotted + box = query saturated, unknown. Hits can be rejected and do not guarantee a prediction. Only executed forward cone queries are shown, not current sight or firing permission.") { style = { whiteSpace = WhiteSpace.Normal } }); rootVisualElement.Add(new Button(FrameObserverAndMarkers) { text = "Frame observer and markers" }); var scroll = new ScrollView(); scroll.style.flexGrow = 1; facts = new Label { name = "enemy-observation-facts" }; facts.style.whiteSpace = WhiteSpace.Normal; facts.style.marginLeft = facts.style.marginRight = 8; scroll.Add(facts); rootVisualElement.Add(scroll); rootVisualElement.schedule.Execute(Refresh).Every(150); } private void Refresh() { if (facts == null) return; if (!HasActiveObserver) { facts.text = "No active observer. No accepted move destination.\nChoose a living host shooter. After reset, choose the new actor; close this window or disable markers to remove the overlay."; if (markers) SceneView.RepaintAll(); return; } double now = perception.Actor.Now; var target = perception.SelectedTargetAtLastDecision; perception.CopyInspectionSnapshots(snapshots); var text = new StringBuilder(); text.AppendLine("Observer: " + perception.name); text.AppendLine($"Selected at last runtime decision: {(target.HasValue ? target.Value.ToString() : "none")} — {perception.SelectionReason}"); text.AppendLine($"Preference remaining: {System.Math.Max(0, perception.HoldUntil - now):F2}s\nLast invalidation: {perception.LastInvalidation}"); foreach (var snapshot in snapshots) { text.AppendLine($"\nTarget {snapshot.Target} | visible at sample: {snapshot.VisibleAtLastSample} | reaction ready: {snapshot.ReactionReady}"); text.AppendLine($"Sample age: {(double.IsNaN(snapshot.SampledAt) ? "not sampled" : (now - snapshot.SampledAt).ToString("F2") + "s")}"); text.AppendLine($"Personal evidence retention remaining: {(double.IsNaN(snapshot.PersonalExpiresAt) ? "none" : System.Math.Max(0, snapshot.PersonalExpiresAt - now).ToString("F2") + "s")}"); Append(text, "Personal (sphere)", snapshot.Personal, now, snapshot.EvidenceLifetimeSeconds); Append(text, "Report (cross)", snapshot.Report, now, snapshot.EvidenceLifetimeSeconds); Append(text, "Hearing (region box)", snapshot.Heard, now, snapshot.EvidenceLifetimeSeconds); } var movement = perception.GetComponent(); var plan = movement != null ? movement.AcceptedPlan : null; text.AppendLine(plan == null ? "\nNo accepted move destination." : $"\nAccepted move destination: {plan.Destination:F2}\nFull accepted planned route: {plan.CornerCount} corners (includes already-travelled sections)."); if (plan != null && plan.ArrivalFacingPoint.HasValue) text.AppendLine($"Plan's captured facing point: {plan.ArrivalFacingPoint.Value:F2}; may differ from latest personal evidence."); text.AppendLine(ProjectI.Debugging.EnemyBehaviorDiagnostics.CaptureInvestigation(perception.GetComponent(), now)); facts.text = text.ToString(); if (markers) SceneView.RepaintAll(); } // Ephemeral immutable descriptions shared by drawing/framing and inspection tests; no runtime state is written. public IReadOnlyList CaptureSceneMarkers() { var result = new List(); if (!HasActiveObserver) return result; double now = perception.Actor.Now; perception.CopyInspectionSnapshots(snapshots); foreach (var snapshot in snapshots) { if (snapshot.Personal.HasValue) { var personal = snapshot.Personal.Value; double age = System.Math.Max(0, now - personal.ObservedAt); bool expired = now >= personal.ObservedAt + snapshot.EvidenceLifetimeSeconds; var kind = expired ? MarkerKind.ExpiredSight : snapshot.VisibleAtLastSample ? MarkerKind.SampledSight : MarkerKind.RememberedSight; string label = expired ? "Expired observation history" : snapshot.VisibleAtLastSample ? "Personal sight at last sample" : "Remembered personal sight"; result.Add(new SceneMarker(kind, personal.Position, $"{label} · {snapshot.Target}\nage {age:F1}s" + (expired ? " · not active memory" : $" · {System.Math.Max(0, snapshot.PersonalExpiresAt - now):F1}s remaining"))); } if (snapshot.Report.HasValue && now < snapshot.Report.Value.ObservedAt + snapshot.EvidenceLifetimeSeconds) result.Add(new SceneMarker(MarkerKind.Report, snapshot.Report.Value.Position, "Scoped report · " + snapshot.Target)); if (snapshot.Heard.HasValue && now < snapshot.Heard.Value.ObservedAt + snapshot.EvidenceLifetimeSeconds) result.Add(new SceneMarker(MarkerKind.Hearing, snapshot.Heard.Value.Position, "Heard region · " + snapshot.Target, EnemyCombatNoise.CellSize * .5f)); } var movement = perception.GetComponent(); var plan = movement != null ? movement.AcceptedPlan : null; if (plan != null) result.Add(new SceneMarker(MarkerKind.Destination, plan.Destination, "Accepted move destination", perception.Actor.Owner.Navigation.radius)); var episode = perception.GetComponent()?.ActiveInvestigation; if (episode != null) { for (int i = 0; i < episode.HypothesisCount; i++) { string source = episode.HypothesisFromWallHit(i) ? "Wall-hit prediction" : "Normal search prediction"; result.Add(new SceneMarker(episode.HypothesisChecked(i) ? MarkerKind.CheckedSample : MarkerKind.Hypothesis, episode.HypothesisPoint(i) + Vector3.up * .15f, $"{source} {i}\n{(episode.HypothesisChecked(i) ? "Checked sample; player not found" : "Assumption; unchecked hiding sample")}\ninitial weight {episode.HypothesisWeight(i):F2}")); } for (int i = 0; i < episode.ViewpointCount; i++) { if (episode.ViewpointFailed(i)) result.Add(new SceneMarker(MarkerKind.UnknownViewpoint, episode.ViewpointPoint(i), $"Failed viewpoint {i}; space unknown")); if (i == episode.ActiveCandidate) result.Add(new SceneMarker(MarkerKind.CommittedViewpoint, episode.ViewpointPoint(i), $"Committed viewpoint {i}; {episode.Phase}\ngain {episode.ActiveGain:F2}, score {episode.ActiveScore:F2}")); } } return result; } public IReadOnlyList CaptureSceneRays() { var result = new List(); if (!HasActiveObserver) return result; var episode = perception.GetComponent()?.ActiveInvestigation; if (episode != null) for (int i = 0; i < episode.CoverRayCapacity; i++) if (episode.TryGetCoverRaySample(i, out var sample)) result.Add(new SceneRay(i, sample)); return result; } public void FrameObserverAndMarkers() { if (!HasActiveObserver) return; var bounds = new Bounds(perception.transform.position + Vector3.up, Vector3.one * 2); foreach (var marker in CaptureSceneMarkers()) { bounds.Encapsulate(marker.Position - Vector3.one * marker.Radius); bounds.Encapsulate(marker.Position + Vector3.one * marker.Radius); } foreach (var ray in CaptureSceneRays()) { bounds.Encapsulate(ray.Sample.Origin); bounds.Encapsulate(ray.Sample.End); } var movement = perception.GetComponent(); var plan = movement != null ? movement.AcceptedPlan : null; if (plan != null) for (int i = 0; i < plan.CornerCount; i++) bounds.Encapsulate(plan.Corner(i)); bounds.Expand(2); (SceneView.lastActiveSceneView != null ? SceneView.lastActiveSceneView : GetWindow()).Frame(bounds, false); } private static void Append(StringBuilder text, string name, EnemyObservation? value, double now, double lifetime) { if (!value.HasValue) { text.AppendLine(name + ": none"); return; } var observation = value.Value; text.AppendLine($"{name}: {observation.Kind}, original age {now - observation.ObservedAt:F2}s, at {observation.Position:F2}"); text.AppendLine(now < observation.ObservedAt + lifetime ? " Retained evidence; active search has separate limits" : " Expired history"); text.AppendLine($" Source {observation.Source.TargetId}/{observation.Source.Lifetime} (spawn {observation.SourceSpawnIdentity}), region radius {observation.RegionRadius:F2}m"); } private void DrawMarkers(SceneView view) { if (!markers || !HasActiveObserver || Event.current.type != EventType.Repaint) return; var oldColor = Handles.color; var oldDepth = Handles.zTest; try { Handles.zTest = UnityEngine.Rendering.CompareFunction.Always; var style = new GUIStyle(EditorStyles.helpBox) { fontStyle = FontStyle.Bold }; style.normal.textColor = EditorGUIUtility.isProSkin ? Color.white : Color.black; foreach (var ray in CaptureSceneRays()) { var sample = ray.Sample; string outcome; if (sample.Outcome == EnemyInvestigationEpisode.CoverRayOutcome.WallHit || sample.Outcome == EnemyInvestigationEpisode.CoverRayOutcome.OtherWorldHit) { bool wall = sample.Outcome == EnemyInvestigationEpisode.CoverRayOutcome.WallHit; Handles.color = wall ? new Color(1, .55f, .1f) : Color.gray; Handles.DrawAAPolyLine(2, sample.Origin, sample.End); if (wall) { Handles.DrawLine(sample.End - Vector3.right * .12f, sample.End + Vector3.right * .12f); Handles.DrawLine(sample.End - Vector3.up * .12f, sample.End + Vector3.up * .12f); } else Handles.DrawWireCube(sample.End, Vector3.one * .2f); outcome = wall ? "wall hit" : "other world hit; rejected surface"; } else { bool unknown = sample.Outcome == EnemyInvestigationEpisode.CoverRayOutcome.Saturated; Handles.color = unknown ? Color.magenta : Color.cyan; Handles.DrawDottedLine(sample.Origin, sample.End, unknown ? 10 : 4); if (unknown) Handles.DrawWireCube(sample.End, Vector3.one * .2f); else Handles.DrawWireDisc(sample.End, view.camera != null ? view.camera.transform.forward : Vector3.up, .1f); outcome = unknown ? "query saturated; unknown" : "no world hit"; } Handles.Label(sample.End + Vector3.up * .2f, $"Cone ray {ray.Index + 1}: {outcome}", style); } foreach (var marker in CaptureSceneMarkers()) { Vector3 point = marker.Position; Handles.color = marker.Kind == MarkerKind.SampledSight ? Color.green : marker.Kind == MarkerKind.RememberedSight ? new Color(1, .65f, .1f) : marker.Kind == MarkerKind.ExpiredSight ? Color.gray : marker.Kind == MarkerKind.Report ? Color.yellow : Color.cyan; if (marker.Kind == MarkerKind.Hypothesis) { Handles.color = new Color(.8f, .5f, 1); Vector3 a = point + Vector3.up * .3f, b = point + Vector3.right * .3f, c = point - Vector3.up * .3f, d = point - Vector3.right * .3f; Handles.DrawLine(a, b); Handles.DrawLine(b, c); Handles.DrawLine(c, d); Handles.DrawLine(d, a); } else if (marker.Kind == MarkerKind.CheckedSample) { Handles.color = Color.white; Handles.DrawWireCube(point, Vector3.one * .35f); } else if (marker.Kind == MarkerKind.UnknownViewpoint) { Handles.color = Color.red; Handles.DrawLine(point - new Vector3(.3f, 0, .3f), point + new Vector3(.3f, 0, .3f)); Handles.DrawLine(point - new Vector3(.3f, 0, -.3f), point + new Vector3(.3f, 0, -.3f)); } else if (marker.Kind == MarkerKind.Destination || marker.Kind == MarkerKind.CommittedViewpoint) { Handles.DrawWireDisc(point + Vector3.up * .06f, Vector3.up, Mathf.Max(.35f, marker.Radius)); Handles.DrawLine(point, point + Vector3.up * .8f); } else if (marker.Kind == MarkerKind.Report) { Handles.DrawLine(point - Vector3.right * .4f, point + Vector3.right * .4f); Handles.DrawLine(point - Vector3.up * .4f, point + Vector3.up * .4f); } else if (marker.Kind == MarkerKind.Hearing) Handles.DrawWireCube(point, Vector3.one * marker.Radius * 2); else if (marker.Kind == MarkerKind.ExpiredSight) { Handles.DrawWireDisc(point, Vector3.up, .3f); Handles.DrawWireDisc(point, Vector3.right, .3f); Handles.DrawWireDisc(point, Vector3.forward, .3f); } else { Handles.SphereHandleCap(0, point, Quaternion.identity, Mathf.Clamp(HandleUtility.GetHandleSize(point) * .06f, .2f, .5f), EventType.Repaint); Handles.DrawDottedLine(perception.transform.position + Vector3.up, point, 4); } Handles.Label(point + Vector3.up * (marker.Kind == MarkerKind.Destination ? .8f : .3f), marker.Label, style); } var movement = perception.GetComponent(); var plan = movement != null ? movement.AcceptedPlan : null; if (plan != null && plan.CornerCount > 1) { var corners = new Vector3[plan.CornerCount]; for (int i = 0; i < corners.Length; i++) corners[i] = plan.Corner(i) + Vector3.up * .08f; Handles.color = Color.cyan; Handles.DrawAAPolyLine(3, corners); Handles.Label(plan.Origin + Vector3.up * .35f, "Full accepted planned route\n(includes travelled sections)", style); } } finally { Handles.color = oldColor; Handles.zTest = oldDepth; } } } }