Merge eureka/aetheria-release-bearing-markers
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
a17dab3677
5 changed files with 500 additions and 15 deletions
|
|
@ -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<Entity, VisibleTargetIndicator> _visibleHostileIndicators = new Dictionary<Entity, VisibleTargetIndicator>();
|
||||
private Dictionary<Entity, VisibleTargetIndicator> _visibleFriendlyIndicators = new Dictionary<Entity, VisibleTargetIndicator>();
|
||||
// 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<Entity, VisibleTargetIndicator> _bearingMarkers = new Dictionary<Entity, VisibleTargetIndicator>();
|
||||
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<Entity> _markerScratch = new List<Entity>();
|
||||
private List<IDisposable> _shipSubscriptions = new List<IDisposable>();
|
||||
private List<IDisposable> _targetSubscriptions = new List<IDisposable>();
|
||||
private readonly Dictionary<EquippedItem, ShotOutcome> _debugLastShots = new Dictionary<EquippedItem, ShotOutcome>();
|
||||
|
|
@ -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<Prototype>().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,76 @@ 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<VisibleTargetIndicator>();
|
||||
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<Prototype>().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<Prototype>().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<PlaceUIElementWorldspace>();
|
||||
ApplyLockProgress(indicator, indicator.GetComponent<Rotate>(), 0); // a pooled lock indicator keeps its last tightening
|
||||
_sweepFlashes.Add((indicator, indicator.GetComponentInChildren<Image>(), 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.
|
||||
|
|
|
|||
|
|
@ -306,9 +306,13 @@ public abstract class Entity
|
|||
VisibleEntities.Remove(remove.Value);
|
||||
VisibleEnemies.Remove(remove.Value);
|
||||
VisibleFriendlies.Remove(remove.Value);
|
||||
// A stance lasts the sitting: it goes with an entity gone for good (destroyed, warped away, unloaded), not
|
||||
// with a ship docking, which leaves the zone parented to its dock (TryDock) and rejoins it on undock.
|
||||
if (remove.Value.Parent == null) _iffOverrides.Remove(remove.Value);
|
||||
if (remove.Value == this)
|
||||
{
|
||||
// A ship out of the zone is no longer published to: it holds no warnings (nothing would ever clear them).
|
||||
_paintInbox.Clear();
|
||||
_incomingPaints.Clear();
|
||||
}
|
||||
ReleaseStance(remove.Value);
|
||||
if (_grudgeSubscriptions.TryGetValue(remove.Value, out var grudgeSubs))
|
||||
{
|
||||
foreach (var s in grudgeSubs) s.Dispose();
|
||||
|
|
@ -985,6 +989,14 @@ public abstract class Entity
|
|||
item.EquippableItem.IsDestroyed
|
||||
&& EntityInfoGathered.TryGetValue(subject, out var info) && info >= ItemManager.GameplaySettings.TargetGearInfoThreshold;
|
||||
|
||||
// A stance lasts the sitting: it goes with a subject gone for good (destroyed, warped away, unloaded) once the book no
|
||||
// longer remembers it, not with a ship docking, which leaves the zone parented to its dock (TryDock) and rejoins it on
|
||||
// undock. Called when the subject leaves the zone and when the book drops it, so the override and the book end together.
|
||||
private void ReleaseStance(Entity subject)
|
||||
{
|
||||
if (subject.Parent == null && !Zone.Entities.Contains(subject) && !_reveals.ContainsKey(subject)) _iffOverrides.Remove(subject);
|
||||
}
|
||||
|
||||
// The book's one way out (caller: Sense): `item` null ends every reveal of `subject`, otherwise that item's.
|
||||
// The subject's key goes with its last entry.
|
||||
private void EndReveals(Entity subject, EquippedItem item)
|
||||
|
|
@ -992,13 +1004,21 @@ public abstract class Entity
|
|||
if (!_reveals.TryGetValue(subject, out var book)) return;
|
||||
if (item != null) book.Remove(item);
|
||||
else book.Clear();
|
||||
if (book.Count == 0) _reveals.Remove(subject);
|
||||
if (book.Count != 0) return;
|
||||
_reveals.Remove(subject);
|
||||
ReleaseStance(subject);
|
||||
}
|
||||
|
||||
// The subjects this observer holds any reveal of, departed ones included until the prune: with VisibleEntities, every
|
||||
// contact it has (ContactOf is Detected for a tracked subject or one with a young reveal).
|
||||
public IReadOnlyCollection<Entity> RevealedSubjects => _reveals.Keys;
|
||||
|
||||
// The hostile subjects this observer holds only by a bearing: revealed, Detected, not Tracked (what bearing markers and
|
||||
// the music show). Hostile is the observer's own live stance (IsHostileTo): the observer always knows its own decision,
|
||||
// and an override outlives a departed subject exactly as long as the book remembers it (ReleaseStance). Reads the book
|
||||
// and ContactOf, never the subject's Position or gear.
|
||||
public IEnumerable<Entity> 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 +1036,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<EquippableItemData> 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)
|
||||
|
|
|
|||
|
|
@ -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<TextMeshProUGUI>();
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -141,6 +141,69 @@ 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(30, 0, 40), 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
|
||||
}
|
||||
|
||||
// A lie within a category is the ordinary lie: a gun that claims another weapon class is labelled with the claim.
|
||||
[Fact]
|
||||
public void ClaimedClassesNameTheClaimWithinACategory()
|
||||
{
|
||||
var e = Add(float3(0, 0, 0), new[] { "Gun" });
|
||||
var o = Add(float3(30, 0, 40), new[] { "Eye" });
|
||||
var gun = Guns(e)[0];
|
||||
var claimed = DataOf("Beam");
|
||||
Assert.True(gun.Data.Behaviors.Any(b => b is WeaponData) && claimed.Behaviors.Any(b => b is WeaponData), "fixture: truth and claim are both weapons");
|
||||
e.Claim(gun, claimed);
|
||||
Fire(e, gun);
|
||||
|
||||
Assert.Same(claimed, Assert.Single(o.ClaimedClasses(e)));
|
||||
Assert.NotSame(gun.Data, claimed); // fixture: the claim is not the truth
|
||||
}
|
||||
|
||||
// The label's order is the claimed names', never the order the reveals were made in.
|
||||
[Fact]
|
||||
public void ClaimedClassesAreOrderedByName()
|
||||
{
|
||||
var locker = Add(float3(0, 0, 0), new[] { "Locker", "Gun" });
|
||||
var target = Add(float3(30, 0, 40), new[] { "Eye" });
|
||||
var lockItem = locker.Equipment.Single(i => i.Behaviors.Any(b => b is LockWeapon));
|
||||
var gunItem = Guns(locker)[0];
|
||||
locker.Claim(lockItem, DataOf("Eye"));
|
||||
locker.Claim(gunItem, DataOf("Shout"));
|
||||
locker.SetIff(target, true);
|
||||
Fire(locker, gunItem); // the later name is revealed first
|
||||
locker.EntityInfoGathered[target] = 1f;
|
||||
locker.SetTarget(target);
|
||||
locker.Aim = normalize(target.Position - locker.Position);
|
||||
Tick(.5f);
|
||||
Tick(.5f);
|
||||
|
||||
Assert.True(target.RevealsOf(locker).Count() >= 2, "fixture: both items revealed");
|
||||
Assert.Equal(new[] { "Eye", "Shout" }, target.ClaimedClasses(locker).Select(c => c.Name));
|
||||
}
|
||||
|
||||
// The emission a ping makes is stamped by the ship that makes it. Mutation: Emit stamps item.Data.
|
||||
[Fact]
|
||||
public void AHeardPingCarriesTheClaim()
|
||||
|
|
|
|||
|
|
@ -667,7 +667,7 @@ public sealed partial class ContactRevealTests : IDisposable
|
|||
case "death": e.Hull.Durability = 0f; e.HullDamage.OnNext(1f); break;
|
||||
default: throw new Exception("fixture: " + how);
|
||||
}
|
||||
Assert.DoesNotContain(e, zone.Entities);
|
||||
Assert.False(zone.Entities.Contains(e), "fixture: the subject left the zone");
|
||||
}
|
||||
|
||||
// Leaves the ship showing exactly `strength` of light: whatever its guns left glowing is put out first.
|
||||
|
|
@ -1054,6 +1054,27 @@ public sealed partial class ContactRevealTests : IDisposable
|
|||
Assert.False(o.ContactOf(p).Detected);
|
||||
}
|
||||
|
||||
// A ship out of the zone (docked, destroyed, warped) is no longer published to, so it holds no warnings: the one it
|
||||
// held when it left is gone, and every reader sees an empty inbox.
|
||||
[Fact]
|
||||
public void AShipOutOfTheZoneHoldsNoWarnings()
|
||||
{
|
||||
var p = Add(float3(0, 0, 100), new[] { "Eye" });
|
||||
var o = Add(float3(0, 0, 0));
|
||||
var sensor = (Sensor) p.Equipment.Single(i => i.Behaviors.Any(b => b is Sensor)).Behaviors.Single(b => b is Sensor);
|
||||
Tick(.01f);
|
||||
sensor.Ping();
|
||||
for (var tick = 0; tick < 100 && o.IncomingPaints.Count == 0; tick++) Tick(.1f);
|
||||
Assert.Single(o.IncomingPaints); // fixture: the ping crossed o
|
||||
|
||||
_zone.Remove(o);
|
||||
for (var tick = 0; tick < 5; tick++)
|
||||
{
|
||||
Tick(.1f);
|
||||
Assert.Empty(o.IncomingPaints);
|
||||
}
|
||||
}
|
||||
|
||||
// An observer's own gear is not another ship's act: a ship that fires its own gun, with its own sensor at the
|
||||
// very place of the light, records nothing of itself, while a neighbour does record the gun.
|
||||
[Fact]
|
||||
|
|
@ -1196,7 +1217,7 @@ public sealed partial class ContactRevealTests : IDisposable
|
|||
killerGun.Activate();
|
||||
for (var ticks = 0; !killed && ticks < 20; ticks++) Tick(.1f);
|
||||
Assert.True(killed, "fixture: the kill lands inside a tick");
|
||||
Assert.DoesNotContain(e, _zone.Entities);
|
||||
Assert.False(_zone.Entities.Contains(e), "fixture: the kill took the subject out of the zone");
|
||||
|
||||
Assert.Equal(2, strong.RevealsOf(e).Count()); // this tick's Sense came before the kill
|
||||
|
||||
|
|
@ -1345,6 +1366,222 @@ 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);
|
||||
Assert.True(o.IsHostileTo(e), "the override outlives the departure while the book remembers the subject; nothing restates it");
|
||||
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");
|
||||
Assert.False(o.IsHostileTo(e), "the book dropped the subject, and the override went with it");
|
||||
o.Deactivate();
|
||||
Assert.Equal(0, o.RememberedHostiles.Count());
|
||||
}
|
||||
|
||||
// A hostile that fired and stayed in the zone, dark: the book keeps it (an in-zone subject is never pruned), so only
|
||||
// ContactOf's Detected ends its listing, exactly when its bearing is BearingMemory old.
|
||||
[Fact]
|
||||
public void ALapsedHostileInTheZoneIsNotRemembered()
|
||||
{
|
||||
var e = Add(float3(0, 0, 100), new[] { "Gun" });
|
||||
var o = Add(float3(0, 0, 0), new[] { "Eye" });
|
||||
o.SetIff(e, true);
|
||||
var gun = Guns(e)[0];
|
||||
Fire(e, gun);
|
||||
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");
|
||||
Assert.True(o.RememberedHostiles.Contains(e), "fixture: listed while young");
|
||||
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");
|
||||
Assert.True(o.RevealedSubjects.Contains(e), "fixture: the in-zone subject is still in the book");
|
||||
Assert.False(o.ContactOf(e).Detected, "fixture: the contact is no longer Detected");
|
||||
Assert.False(o.RememberedHostiles.Contains(e));
|
||||
}
|
||||
|
||||
// A remembered hostile is decided by the book, never by where the subject stands: loud pingers 500 to 600 away on three
|
||||
// sides are heard (Roar: 300 over .5) and listed, each at the bearing it was heard on.
|
||||
[Fact]
|
||||
public void RememberedHostilesDoNotDependOnDistance()
|
||||
{
|
||||
var o = Add(float3(0, 0, 0), new[] { "Eye" });
|
||||
var spots = new[] { float3(550, 0, 0), float3(-520, 0, 100), float3(0, 0, 590) };
|
||||
var pingers = spots.Select(spot => Add(spot, new[] { "Roar" })).ToArray();
|
||||
foreach (var p in pingers) o.SetIff(p, true);
|
||||
foreach (var p in pingers) PingAt(p);
|
||||
|
||||
foreach (var p in pingers)
|
||||
{
|
||||
Assert.False(o.ContactOf(p).Tracked, "fixture: held by a bearing only");
|
||||
Assert.True(length(p.Position - o.Position) > 500f, "fixture: far from the observer");
|
||||
Assert.True(o.RememberedHostiles.Contains(p));
|
||||
var toward = normalize((p.Position - o.Position).xz);
|
||||
AssertBearing(toward.x, toward.y, o.ContactOf(p).Bearing);
|
||||
}
|
||||
Assert.Equal(3, o.RememberedHostiles.Count());
|
||||
}
|
||||
|
||||
// The observer's stance is its own live decision, not something it saw: toggled after the last act, while the subject is
|
||||
// remembered and untracked, the listing follows at once, in both directions.
|
||||
[Fact]
|
||||
public void AStanceToggledAfterTheLastActShowsAtOnce()
|
||||
{
|
||||
var e = Add(float3(0, 0, 100), new[] { "Gun" });
|
||||
var q = Add(float3(40, 0, 100), new[] { "Gun" });
|
||||
var o = Add(float3(0, 0, 0), new[] { "Eye" });
|
||||
o.SetIff(e, true);
|
||||
o.SetIff(q, false);
|
||||
Fire(e, Guns(e)[0]);
|
||||
Fire(q, Guns(q)[0]);
|
||||
ShowOnly(e, 1f, Guns(e)[0]);
|
||||
ShowOnly(q, 1f, Guns(q)[0]);
|
||||
Assert.True(e.Visibility * o.PassiveInfo(e.Position.xz, 1f) <= _items.GameplaySettings.TargetDetectionInfoThreshold, "fixture: e unseen");
|
||||
Assert.True(q.Visibility * o.PassiveInfo(q.Position.xz, 1f) <= _items.GameplaySettings.TargetDetectionInfoThreshold, "fixture: q unseen");
|
||||
Assert.True(o.RememberedHostiles.Contains(e) && !o.RememberedHostiles.Contains(q), "fixture: stances as made");
|
||||
|
||||
o.SetIff(e, false);
|
||||
o.SetIff(q, true);
|
||||
Tick(.1f);
|
||||
Assert.True(o.ContactOf(e).Detected && !o.ContactOf(e).Tracked && o.ContactOf(q).Detected && !o.ContactOf(q).Tracked, "fixture: both still remembered by bearing");
|
||||
Assert.False(o.RememberedHostiles.Contains(e), "hostile to neutral after the act");
|
||||
Assert.True(o.RememberedHostiles.Contains(q), "neutral to hostile after the act");
|
||||
}
|
||||
|
||||
// A hostile held only by the observer's override that departs keeps its marker until the bearing lapses and not a tick
|
||||
// longer, and its override is gone with the book entry; a stance for a subject the book never held goes at once.
|
||||
[Fact]
|
||||
public void ADepartedOverrideHostileKeepsItsMarkerThenLosesItsStance()
|
||||
{
|
||||
var e = Add(float3(0, 0, 100), new[] { "Gun" });
|
||||
var never = Add(float3(40, 0, 100), new[] { "Gun" });
|
||||
var o = Add(float3(0, 0, 0), new[] { "Eye" });
|
||||
o.SetIff(e, true);
|
||||
o.SetIff(never, true);
|
||||
Fire(e, Guns(e)[0]);
|
||||
var freshest = Freshest(o, e);
|
||||
ShowOnly(e, 1f, Guns(e)[0]);
|
||||
_zone.Remove(never);
|
||||
Assert.False(o.IsHostileTo(never), "a subject the book never held loses its stance on leaving");
|
||||
_zone.Remove(e);
|
||||
Assert.True(o.RememberedHostiles.Contains(e), "marker kept on departure");
|
||||
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));
|
||||
Assert.Equal(!lapsed, o.IsHostileTo(e));
|
||||
}
|
||||
Assert.True(lapsed, "fixture: the bearing lapsed");
|
||||
}
|
||||
|
||||
// The memory window is exclusive: at Zone.Time - freshest == BearingMemory the bearing has lapsed (Detected, whether the
|
||||
// subject stays or departs), and a departed subject's book entry (and the stance with it) goes on that tick. The sighting is on a coarse float time (.5) so
|
||||
// that freshest + memory is representable and a step can land on it exactly.
|
||||
[Theory]
|
||||
[InlineData(false)]
|
||||
[InlineData(true)]
|
||||
public void TheMemoryWindowIsExclusiveAtTheEquality(bool departs)
|
||||
{
|
||||
var e = Add(float3(0, 0, 100), new[] { "Gun" });
|
||||
var o = Add(float3(0, 0, 0), new[] { "Eye" });
|
||||
o.SetIff(e, true);
|
||||
var gun = Guns(e)[0];
|
||||
Tick((float) (.5 - (double) .1f));
|
||||
Fire(e, gun);
|
||||
var freshest = Freshest(o, e);
|
||||
Assert.Equal(.5f, freshest);
|
||||
ShowOnly(e, 1f, gun);
|
||||
Assert.True(e.Visibility * o.PassiveInfo(e.Position.xz, 1f) <= _items.GameplaySettings.TargetDetectionInfoThreshold, "fixture: unseen");
|
||||
if (departs) _zone.Remove(e);
|
||||
var memory = _items.GameplaySettings.BearingMemory;
|
||||
for (var i = 0; i < 1000 && _zone.Time - freshest + .3f < memory; i++) Tick(.1f);
|
||||
Assert.True(o.RememberedHostiles.Contains(e), "fixture: still listed");
|
||||
var exact = _zone.ExactTime;
|
||||
var d = (float) (memory - (exact - freshest));
|
||||
var found = false;
|
||||
for (var k = 0; k < 400 && !found; k++)
|
||||
foreach (var sign in new[] { 1, -1 })
|
||||
{
|
||||
var c = d;
|
||||
for (var j = 0; j < k; j++) c = sign > 0 ? MathF.BitIncrement(c) : MathF.BitDecrement(c);
|
||||
if ((float) (exact + c) - freshest == memory) { d = c; found = true; break; }
|
||||
}
|
||||
Assert.True(found, "fixture: a step landing exactly on the window edge exists");
|
||||
Tick(d);
|
||||
Assert.Equal(memory, _zone.Time - freshest);
|
||||
Assert.False(o.RememberedHostiles.Contains(e));
|
||||
Assert.False(o.ContactOf(e).Detected);
|
||||
if (departs)
|
||||
{
|
||||
Assert.False(o.RevealedSubjects.Contains(e), "the departed subject leaves the book at the edge");
|
||||
Assert.False(o.IsHostileTo(e), "the book dropped the departed subject at the edge, and the stance with it");
|
||||
}
|
||||
}
|
||||
|
||||
// A stance lasts the sitting: a hostile still in the zone whose every revealed item the observer perceives destroyed
|
||||
// leaves the book, and the observer's decision about it stays.
|
||||
[Fact]
|
||||
public void AStanceOutlastsTheBookEntryOfASubjectStillInTheZone()
|
||||
{
|
||||
var e = Add(float3(0, 0, 60), new[] { "Gun" });
|
||||
var o = Add(float3(0, 0, 0), new[] { "Eye" });
|
||||
o.SetIff(e, true);
|
||||
var gun = Guns(e)[0];
|
||||
Fire(e, gun);
|
||||
Assert.Single(o.RevealsOf(e));
|
||||
o.EntityInfoGathered[e] = 1f;
|
||||
gun.EquippableItem.Durability = 0f;
|
||||
|
||||
Tick(.01f);
|
||||
|
||||
Assert.Empty(o.RevealsOf(e)); // fixture: o perceived the destruction and the book dropped the subject
|
||||
Assert.True(_zone.Entities.Contains(e), "fixture: the subject is still in the zone");
|
||||
Assert.True(o.IsHostileTo(e));
|
||||
}
|
||||
|
||||
// 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)
|
||||
{
|
||||
|
|
@ -1447,4 +1684,50 @@ public sealed partial class ContactRevealTests : IDisposable
|
|||
|
||||
Assert.Equal(0L, smallest);
|
||||
}
|
||||
|
||||
// A docked subject leaves the zone parented to its dock and rejoins on undock: the observer's decision about it outlives
|
||||
// the lapse of the bearing, and is still there when it rejoins.
|
||||
[Fact]
|
||||
public void ADockedSubjectKeepsTheObserversStanceAfterTheBearingLapses()
|
||||
{
|
||||
var e = Add(float3(0, 0, 100), new[] { "Gun" });
|
||||
var dock = Add(float3(200, 0, 0));
|
||||
var o = Add(float3(0, 0, 0), new[] { "Eye" });
|
||||
o.SetIff(e, true);
|
||||
var gun = Guns(e)[0];
|
||||
Fire(e, gun);
|
||||
ShowOnly(e, 1f, gun);
|
||||
Assert.True(e.Visibility * o.PassiveInfo(e.Position.xz, 1f) <= _items.GameplaySettings.TargetDetectionInfoThreshold, "fixture: unseen");
|
||||
e.Parent = dock;
|
||||
_zone.Remove(e);
|
||||
for (var i = 0; i < 100 && o.RevealedSubjects.Contains(e); i++) Tick(.1f);
|
||||
Assert.False(o.RevealedSubjects.Contains(e), "fixture: the book dropped the docked subject");
|
||||
Assert.True(o.IsHostileTo(e), "a docked subject keeps the observer's decision");
|
||||
e.Parent = null;
|
||||
_zone.Admit(e, piloted: false);
|
||||
Assert.True(o.IsHostileTo(e), "and it is still there when the subject rejoins");
|
||||
}
|
||||
|
||||
private static int PaintInbox(Entity e) => ((System.Collections.ICollection) typeof(Entity).GetField("_paintInbox", BindingFlags.NonPublic | BindingFlags.Instance).GetValue(e)).Count;
|
||||
|
||||
// A paint still unpublished in an observer's inbox when the observer leaves the zone is gone: it does not arrive as a
|
||||
// stale burst when the observer is admitted again.
|
||||
[Fact]
|
||||
public void AnObserverLeavingTheZoneLosesItsPendingPaints()
|
||||
{
|
||||
var p = Add(float3(0, 0, 100), new[] { "Eye" });
|
||||
var o = Add(float3(0, 0, 0));
|
||||
var sensor = (Sensor) p.Equipment.Single(i => i.Behaviors.Any(b => b is Sensor)).Behaviors.Single(b => b is Sensor);
|
||||
Tick(.01f);
|
||||
sensor.Ping();
|
||||
var pending = false;
|
||||
for (var tick = 0; tick < 400 && !pending; tick++) { Tick(.02f); pending = PaintInbox(o) > 0; }
|
||||
Assert.True(pending, "fixture: a paint is pending in o's inbox, not yet published");
|
||||
_zone.Remove(o);
|
||||
Assert.Equal(0, PaintInbox(o));
|
||||
Tick(.1f);
|
||||
_zone.Admit(o, piloted: false);
|
||||
Tick(.02f);
|
||||
Assert.Equal(0, o.IncomingPaints.Count);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue