From 7b12fbe417e4abb64caef11894325819b40b169d Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 14 Oct 2026 13:54:43 +0200 Subject: [PATCH] Bearing markers for remembered hostile contacts, labelled with their claimed classes, and a sweep flash per ping Entity.RememberedHostiles and ClaimedClasses derive from the book; music and the new markers share RememberedHostiles. Markers sit on the remembered bearing, never the subject's position; Search warnings are collected per tick into sweep flashes. Unity code is not compiled in this commit's verification (dotnet tests only). Co-Authored-By: Claude Opus 5.5 --- Assets/Scripts/Gameplay/ActionGameManager.cs | 106 ++++++++++++++++-- Assets/Scripts/ServerShared/Entity.cs | 9 ++ .../Scripts/UI/HUD/VisibleTargetIndicator.cs | 25 +++++ tests/Aetheria.Shared.Tests/ClaimTests.cs | 25 +++++ .../ContactRevealTests.cs | 44 ++++++++ 5 files changed, 200 insertions(+), 9 deletions(-) diff --git a/Assets/Scripts/Gameplay/ActionGameManager.cs b/Assets/Scripts/Gameplay/ActionGameManager.cs index e9d6ede53..7b332bad1 100644 --- a/Assets/Scripts/Gameplay/ActionGameManager.cs +++ b/Assets/Scripts/Gameplay/ActionGameManager.cs @@ -162,6 +162,10 @@ public class ActionGameManager : MonoBehaviour public Prototype LockIndicator; // Presentation tuning: how far from the ship, toward the locker, the incoming-lock indicator sits. public float IncomingLockIndicatorDistance = 30; + // Presentation tuning: how far from the ship, toward a remembered hostile's bearing, its marker sits; how long a + // sweep flash takes to fade. + public float BearingMarkerDistance = 40; + public float SearchFlashSeconds = .6f; public PlaceUIElementWorldspace[] Crosshairs; public GameObject HitMarker; public float HitMarkerDuration; @@ -203,6 +207,13 @@ public class ActionGameManager : MonoBehaviour private (PlaceUIElementWorldspace indicator, Rotate spin) _incomingLockIndicator; private Dictionary _visibleHostileIndicators = new Dictionary(); private Dictionary _visibleFriendlyIndicators = new Dictionary(); + // Display-only: a marker per remembered hostile (shares HostileTargetIndicator's pool), the Search warnings the last + // ticks delivered (bearing, zone time; a per-tick buffer), and the sweep flashes drawn from them. + private readonly Dictionary _bearingMarkers = new Dictionary(); + private readonly List<(float2 bearing, float time)> _pendingSweeps = new List<(float2, float)>(); + private readonly List<(PlaceUIElementWorldspace indicator, Image image, float2 bearing, float time)> _sweepFlashes = + new List<(PlaceUIElementWorldspace, Image, float2, float)>(); + private readonly List _markerScratch = new List(); private List _shipSubscriptions = new List(); private List _targetSubscriptions = new List(); private readonly Dictionary _debugLastShots = new Dictionary(); @@ -1003,6 +1014,12 @@ public class ActionGameManager : MonoBehaviour foreach (var indicator in _visibleFriendlyIndicators) Destroy(indicator.Value.gameObject); _visibleFriendlyIndicators.Clear(); + + foreach (var marker in _bearingMarkers.Values) RetireMarker(marker); + _bearingMarkers.Clear(); + foreach (var flash in _sweepFlashes) RetireFlash(flash.indicator, flash.image); + _sweepFlashes.Clear(); + _pendingSweeps.Clear(); if(_lockingIndicators!=null) foreach(var (_, indicator, _) in _lockingIndicators) indicator.GetComponent().ReturnToPool(); @@ -1268,13 +1285,7 @@ public class ActionGameManager : MonoBehaviour private void UpdateMusic() { var combat = CurrentEntity.VisibleEnemies.Count > 0 || CurrentEntity.IncomingPaints.Count > 0; - if (!combat) - foreach (var subject in CurrentEntity.RevealedSubjects) - if (CurrentEntity.IsHostileTo(subject) && CurrentEntity.ContactOf(subject).Detected) - { - combat = true; - break; - } + combat |= CurrentEntity.RememberedHostiles.Any(); var type = combat ? MusicType.Combat : MusicType.Overworld; if (_music == type) return; _music = type; @@ -1360,7 +1371,15 @@ public class ActionGameManager : MonoBehaviour void EnterNewZone() { if (_zoneEntered) { Clock.Reset(); _zoneEntered = false; } } EnterNewZone(); var steppedZone = Zone; - Clock.Advance(_paused ? 0f : Time.deltaTime, delta => { if (Zone == steppedZone) steppedZone.Update(delta); }); + Clock.Advance(_paused ? 0f : Time.deltaTime, delta => + { + if (Zone != steppedZone) return; + steppedZone.Update(delta); + // A warning lives one tick, so sweeps are collected per tick, never read per frame. + if (CurrentEntity != null && CurrentEntity.Zone == steppedZone) + foreach (var warning in CurrentEntity.IncomingPaints) + if (warning.Mode == PaintMode.Search) _pendingSweeps.Add((warning.Bearing, steppedZone.Time)); + }); EnterNewZone(); } @@ -1476,7 +1495,7 @@ public class ActionGameManager : MonoBehaviour } // The strongest lock on the player's ship, read only from IncomingPaints (never another ship's LockWeapon). - // Only Track paints are locks; Search paints (ping sweeps) are presented by contact-hud, not here. + // Only Track paints are locks; Search paints (ping sweeps) are this class's sweep flashes (UpdateSweepFlashes). // Same curves as the outgoing indicator, driven by the warning's Strength. var warnings = CurrentEntity.IncomingPaints; var strongest = -1; @@ -1491,6 +1510,75 @@ public class ActionGameManager : MonoBehaviour new Vector3(warning.Bearing.x, 0, warning.Bearing.y) * IncomingLockIndicatorDistance; ApplyLockProgress(incomingIndicator, incomingSpin, warning.Strength); } + + UpdateBearingMarkers(); + UpdateSweepFlashes(); + } + + // One marker per remembered hostile (a contact held by a bearing only): at the bearing the book keeps, labelled with the + // classes it claimed. Never at the subject's position, never read from its gear. + private void UpdateBearingMarkers() + { + var remembered = CurrentEntity.RememberedHostiles.ToList(); + _markerScratch.Clear(); + foreach (var subject in _bearingMarkers.Keys) + if (!remembered.Contains(subject)) _markerScratch.Add(subject); + foreach (var subject in _markerScratch) + { + RetireMarker(_bearingMarkers[subject]); + _bearingMarkers.Remove(subject); + } + foreach (var subject in remembered) + { + if (!_bearingMarkers.TryGetValue(subject, out var marker)) + { + marker = HostileTargetIndicator.Instantiate(); + marker.Fill.enabled = false; + _bearingMarkers.Add(subject, marker); + } + marker.gameObject.SetActive(true); + var bearing = CurrentEntity.ContactOf(subject).Bearing; + marker.Place.Target = CurrentEntity.Position.ToUnity() + new Vector3(bearing.x, 0, bearing.y) * BearingMarkerDistance; + marker.ShowLabel(string.Join(", ", CurrentEntity.ClaimedClasses(subject).Select(c => c.Name))); + } + } + + // A pooled indicator goes back as the next hostile indicator will want it: label hidden, fill restored. + private static void RetireMarker(VisibleTargetIndicator marker) + { + marker.ShowLabel(null); + marker.Fill.enabled = true; + marker.GetComponent().ReturnToPool(); + } + + private static void RetireFlash(PlaceUIElementWorldspace indicator, Image image) + { + if (image != null) image.color = new Color(image.color.r, image.color.g, image.color.b, 1); + indicator.GetComponent().ReturnToPool(); + } + + // A ping crossing the player draws one flash toward the pinger, fading over SearchFlashSeconds of zone time. + private void UpdateSweepFlashes() + { + foreach (var (bearing, time) in _pendingSweeps) + { + var indicator = LockIndicator.Instantiate(); + _sweepFlashes.Add((indicator, indicator.GetComponentInChildren(), bearing, time)); + } + _pendingSweeps.Clear(); + for (var i = _sweepFlashes.Count - 1; i >= 0; i--) + { + var (indicator, image, bearing, time) = _sweepFlashes[i]; + var age = Zone.Time - time; + if (age < 0 || age >= SearchFlashSeconds) + { + RetireFlash(indicator, image); + _sweepFlashes.RemoveAt(i); + continue; + } + indicator.Target = CurrentEntity.Position.ToUnity() + new Vector3(bearing.x, 0, bearing.y) * IncomingLockIndicatorDistance; + if (image != null) image.color = new Color(image.color.r, image.color.g, image.color.b, 1 - age / SearchFlashSeconds); + } } // The one lock-indicator curve: outgoing (the player's own lock) and incoming (the warning's strength) both tighten through it. diff --git a/Assets/Scripts/ServerShared/Entity.cs b/Assets/Scripts/ServerShared/Entity.cs index 69bef3888..77efe7955 100644 --- a/Assets/Scripts/ServerShared/Entity.cs +++ b/Assets/Scripts/ServerShared/Entity.cs @@ -999,6 +999,10 @@ public abstract class Entity // contact it has (ContactOf is Detected for a tracked subject or one with a young reveal). public IReadOnlyCollection RevealedSubjects => _reveals.Keys; + // The hostile subjects this observer holds only by a bearing: revealed, Detected, not Tracked (what bearing markers and + // the music show). Reads the book and ContactOf, never the subject's Position or gear. + public IEnumerable RememberedHostiles => RevealedSubjects.Where(s => IsHostileTo(s) && ContactOf(s) is { Detected: true, Tracked: false }); + // What this observer has seen `subject`'s items do: each item's latest act, and nothing the subject's present // gear or Durability says (ruling contact-reads-book-aim-reads-live). A reveal ends only on what this observer // perceives (ruling reveal-ends-on-perception): the destruction of an item of a ship it identifies, the death, @@ -1016,6 +1020,11 @@ public abstract class Entity RevealsOf(subject).Where(r => r.Claimed != null && r.Claimed.Behaviors.Any(b => b is WeaponData)) .OrderBy(r => FittedIndex(subject, r.Item)).ThenBy(r => r.Item.EquippableItem.Lot); + // The classes `subject` has claimed to this observer, distinct and by name: what a bearing marker is labelled with. Reads + // each reveal's Claimed only, never the item's true data (aetheria-ew, classify-from-belief). + public IEnumerable ClaimedClasses(Entity subject) => + RevealsOf(subject).Select(r => r.Claimed).Where(c => c != null).Distinct().OrderBy(c => c.Name, StringComparer.Ordinal); + // A revealed item's place in the subject's equipment; gear the subject no longer carries follows all fitted gear // (ties among it fall to the lot, a stable id), never ahead of it. private static int FittedIndex(Entity subject, EquippedItem item) diff --git a/Assets/Scripts/UI/HUD/VisibleTargetIndicator.cs b/Assets/Scripts/UI/HUD/VisibleTargetIndicator.cs index f12220bd7..db512492e 100644 --- a/Assets/Scripts/UI/HUD/VisibleTargetIndicator.cs +++ b/Assets/Scripts/UI/HUD/VisibleTargetIndicator.cs @@ -2,10 +2,35 @@ using System.Collections; using System.Collections.Generic; using UnityEngine; using UnityEngine.UI; +using TMPro; public class VisibleTargetIndicator : MonoBehaviour { public RectTransform Transform; public Image Fill; public PlaceUIElementWorldspace Place; + public TextMeshProUGUI Label; + + // Null or empty hides the label; the first non-empty text creates it as a child (no scene or prefab edit) and keeps it. + // Pools are shared, so hide the label before the indicator returns to its pool. + public void ShowLabel(string text) + { + var show = !string.IsNullOrEmpty(text); + if (Label == null) + { + if (!show) return; + var child = new GameObject("Label", typeof(RectTransform)); + child.transform.SetParent(transform, false); + Label = child.AddComponent(); + Label.fontSize = 14; + Label.alignment = TextAlignmentOptions.Top; + Label.raycastTarget = false; + var rect = Label.rectTransform; + rect.anchorMin = rect.anchorMax = new Vector2(.5f, 0); + rect.pivot = new Vector2(.5f, 1); + rect.sizeDelta = new Vector2(200, 24); + } + Label.gameObject.SetActive(show); + if (show && Label.text != text) Label.text = text; + } } diff --git a/tests/Aetheria.Shared.Tests/ClaimTests.cs b/tests/Aetheria.Shared.Tests/ClaimTests.cs index c07d5f413..8f0e96545 100644 --- a/tests/Aetheria.Shared.Tests/ClaimTests.cs +++ b/tests/Aetheria.Shared.Tests/ClaimTests.cs @@ -141,6 +141,31 @@ public sealed partial class ContactRevealTests Assert.Same(claimed, reveal.Claimed); } + // What a bearing marker is labelled with: the classes the subject claimed, never the item's true data; one class once. + [Fact] + public void ClaimedClassesNameTheClaim() + { + var locker = Add(float3(0, 0, 0), new[] { "Locker", "Gun" }); + var target = Add(float3(0, 0, 200), new[] { "Eye" }); + var lockItem = locker.Equipment.Single(i => i.Behaviors.Any(b => b is LockWeapon)); + var gunItem = Guns(locker)[0]; + var claimed = DataOf("Eye"); + locker.Claim(lockItem, claimed); + locker.Claim(gunItem, claimed); + locker.SetIff(target, true); + Fire(locker, gunItem); + locker.EntityInfoGathered[target] = 1f; + locker.SetTarget(target); + locker.Aim = normalize(target.Position - locker.Position); + Tick(.5f); + Tick(.5f); + + var items = target.RevealsOf(locker).Select(r => r.Item).ToArray(); + Assert.True(items.Contains(lockItem) && items.Contains(gunItem), "fixture: two items revealed, both claiming Eye"); + Assert.Same(claimed, Assert.Single(target.ClaimedClasses(locker))); // two reveals of one class list it once + Assert.NotSame(lockItem.Data, claimed); // fixture: the claim is not the truth + } + // The emission a ping makes is stamped by the ship that makes it. Mutation: Emit stamps item.Data. [Fact] public void AHeardPingCarriesTheClaim() diff --git a/tests/Aetheria.Shared.Tests/ContactRevealTests.cs b/tests/Aetheria.Shared.Tests/ContactRevealTests.cs index 72af4bd42..fb8d1ef7e 100644 --- a/tests/Aetheria.Shared.Tests/ContactRevealTests.cs +++ b/tests/Aetheria.Shared.Tests/ContactRevealTests.cs @@ -1345,6 +1345,50 @@ public sealed partial class ContactRevealTests : IDisposable Assert.Equal(0, o.RevealedSubjects.Count); // counts, not the collection: a failure message would print whole entities } + // The hostile subjects held only by a bearing (what bearing markers and the music show): a hostile that fired and is + // untracked is listed; the same kind of subject tracked is not; a non-hostile that fired is not; one that departs + // unseen is listed exactly until its bearing is BearingMemory old; Deactivate empties it. + [Fact] + public void RememberedHostilesAreBearingOnlyHostiles() + { + var e = Add(float3(0, 0, 100), new[] { "Gun" }); + var t = Add(float3(50, 0, 0), new[] { "Gun" }); + var n = Add(float3(40, 0, 100), new[] { "Gun" }); + var o = Add(float3(0, 0, 0), new[] { "Eye" }); + o.SetIff(e, true); + o.SetIff(t, true); + o.SetIff(n, false); + var gun = Guns(e)[0]; + Fire(e, gun); + Fire(n, Guns(n)[0]); + Fire(t, Guns(t)[0]); + Assert.False(o.ContactOf(e).Tracked, "fixture: held by a bearing only"); + Assert.True(o.RevealedSubjects.Contains(n) && o.IsHostileTo(n) == false, "fixture: n is revealed and not hostile"); + + Assert.True(o.RememberedHostiles.Contains(e), "a hostile that fired, untracked"); + Assert.False(o.RememberedHostiles.Contains(n), "a non-hostile that fired"); + Assert.True(o.RememberedHostiles.Contains(t), "fixture: t is untracked so far"); + o.EntityInfoGathered[t] = 1f; + Assert.True(o.ContactOf(t).Tracked, "fixture: t is tracked now"); + Assert.False(o.RememberedHostiles.Contains(t), "the same hostile tracked is a hostile indicator's"); + + var freshest = Freshest(o, e); + ShowOnly(e, 1f, gun); + Assert.True(e.Visibility * o.PassiveInfo(e.Position.xz, 1f) <= _items.GameplaySettings.TargetDetectionInfoThreshold, "fixture: its last light is below o's gate"); + _zone.Remove(e); + var memory = _items.GameplaySettings.BearingMemory; + var lapsed = false; + for (var i = 0; i < 200 && !lapsed; i++) + { + Tick(.1f); + lapsed = _zone.Time - freshest >= memory; + Assert.Equal(!lapsed, o.RememberedHostiles.Contains(e)); // exactly while the bearing is remembered + } + Assert.True(lapsed, "fixture: the bearing lapsed"); + o.Deactivate(); + Assert.Equal(0, o.RememberedHostiles.Count()); + } + // The first field path from `root` to `target`, not entering the zone or the item manager, or null. private static string PathFrom(object root, object target) {