diff --git a/Assets/Scripts/Gameplay/ActionGameManager.cs b/Assets/Scripts/Gameplay/ActionGameManager.cs index 70db9ae92..2decaca43 100644 --- a/Assets/Scripts/Gameplay/ActionGameManager.cs +++ b/Assets/Scripts/Gameplay/ActionGameManager.cs @@ -157,6 +157,8 @@ public class ActionGameManager : MonoBehaviour public Prototype FriendlyTargetIndicator; public PlaceUIElementWorldspace ViewDot; public Prototype LockIndicator; + // Presentation tuning: how far from the ship, toward the locker, the incoming-lock indicator sits. + public float IncomingLockIndicatorDistance = 30; public PlaceUIElementWorldspace[] Crosshairs; public GameObject HitMarker; public float HitMarkerDuration; @@ -195,6 +197,7 @@ public class ActionGameManager : MonoBehaviour private TextMeshProUGUI _faceAimMark; private (HardpointData[] hardpoints, Transform[] barrels, PlaceUIElementWorldspace crosshair)[] _articulationGroups; private (LockWeapon targetLock, PlaceUIElementWorldspace indicator, Rotate spin)[] _lockingIndicators; + private (PlaceUIElementWorldspace indicator, Rotate spin) _incomingLockIndicator; private Dictionary _visibleHostileIndicators = new Dictionary(); private Dictionary _visibleFriendlyIndicators = new Dictionary(); private List _shipSubscriptions = new List(); @@ -999,6 +1002,8 @@ public class ActionGameManager : MonoBehaviour if(_lockingIndicators!=null) foreach(var (_, indicator, _) in _lockingIndicators) indicator.GetComponent().ReturnToPool(); + if(_incomingLockIndicator.indicator != null) _incomingLockIndicator.indicator.GetComponent().ReturnToPool(); + _incomingLockIndicator = default; DisablePlayerInput(); Cursor.lockState = CursorLockMode.None; GameplayUI.gameObject.SetActive(false); @@ -1142,6 +1147,10 @@ public class ActionGameManager : MonoBehaviour var i = LockIndicator.Instantiate(); return (x, i, i.GetComponent()); }).ToArray(); + + var incoming = LockIndicator.Instantiate(); + incoming.gameObject.SetActive(false); + _incomingLockIndicator = (incoming, incoming.GetComponent()); } private void UpdatePlayerPanel() @@ -1449,6 +1458,24 @@ public class ActionGameManager : MonoBehaviour spin.Speed = Settings.GameplaySettings.LockSpinSpeed.Evaluate(targetLock.Lock); } } + + // The strongest lock on the player's ship, read only from IncomingLocks (never another ship's LockWeapon). + // Same curves as the outgoing indicator, driven by the warning's Strength. + var warnings = CurrentEntity.IncomingLocks; + var strongest = -1; + for (var w = 0; w < warnings.Count; w++) + if (strongest < 0 || warnings[w].Strength > warnings[strongest].Strength) strongest = w; + var (incomingIndicator, incomingSpin) = _incomingLockIndicator; + incomingIndicator.gameObject.SetActive(strongest >= 0); + if (strongest >= 0) + { + var warning = warnings[strongest]; + incomingIndicator.Target = CurrentEntity.Position.ToUnity() + + new Vector3(warning.Bearing.x, 0, warning.Bearing.y) * IncomingLockIndicatorDistance; + incomingIndicator.NoiseAmplitude = Settings.GameplaySettings.LockIndicatorNoiseAmplitude * (1 - warning.Strength); + incomingIndicator.NoiseFrequency = Settings.GameplaySettings.LockIndicatorFrequency.Evaluate(warning.Strength); + incomingSpin.Speed = Settings.GameplaySettings.LockSpinSpeed.Evaluate(warning.Strength); + } } } diff --git a/Assets/Scripts/ServerShared/Behaviors/LockWeapon.cs b/Assets/Scripts/ServerShared/Behaviors/LockWeapon.cs index 16f56b72d..c3e234f47 100644 --- a/Assets/Scripts/ServerShared/Behaviors/LockWeapon.cs +++ b/Assets/Scripts/ServerShared/Behaviors/LockWeapon.cs @@ -102,6 +102,7 @@ public class LockWeapon : InstantWeapon { var lerp = 1 - unlerp(0, 90, degrees); _lock = saturate(_lock + pow(lerp, DirectionImpact) * dt * LockSpeed * pow(Entity.EntityInfoGathered[target], SensorImpact)); + target.ReceivePaint(Entity.Position, Item?.Data, Lock); } else _lock = saturate(_lock - dt * Decay); } @@ -110,3 +111,18 @@ public class LockWeapon : InstantWeapon } } +// What a painted ship senses of one beam on it (ruling sensor-stat-set item 4; strength per ruling +// lock-warning-strength). Strength is the emitter's own lock progress at the paint, 0..1, never summed. +public readonly struct LockWarning +{ + public readonly float2 Bearing; + public readonly EquippableItemData EmitterClass; + public readonly float Strength; + + public LockWarning(float2 bearing, EquippableItemData emitterClass, float strength) + { + Bearing = bearing; + EmitterClass = emitterClass; + Strength = strength; + } +} diff --git a/Assets/Scripts/ServerShared/Entity.cs b/Assets/Scripts/ServerShared/Entity.cs index 59bc38cc9..0a99ee742 100644 --- a/Assets/Scripts/ServerShared/Entity.cs +++ b/Assets/Scripts/ServerShared/Entity.cs @@ -173,6 +173,12 @@ public abstract class Entity public Subject HypothermiaDeath = new Subject(); public Subject TargetedBy = new Subject(); public ReactiveProperty TargetedByCount = new ReactiveProperty(0); + // TargetedBy counts selection, which emits nothing; a lock warning comes only from a paint. LockWeapon.Execute + // reports each tick it paints this ship into the inbox; Update publishes the inbox once, so a warning lives + // exactly one tick after its last paint. + private readonly List _paintInbox = new List(); + private readonly List _incomingLocks = new List(); + public IReadOnlyList IncomingLocks => _incomingLocks; public ReactiveProperty CurrentSecurityLevel = new ReactiveProperty(SecurityLevel.Open); public ReadOnlyReactiveProperty PresencePermitted; @@ -783,6 +789,15 @@ public abstract class Entity VisibleEntities.Clear(); VisibleEnemies.Clear(); VisibleFriendlies.Clear(); + _paintInbox.Clear(); + _incomingLocks.Clear(); + } + + // The only caller is LockWeapon.Execute; strength is the emitter's own Lock at that paint. + internal void ReceivePaint(float3 emitterPosition, EquippableItemData emitterClass, float strength) + { + if (!_active) return; + _paintInbox.Add(new LockWarning(normalize((emitterPosition - Position).xz), emitterClass, strength)); } public Entity(ItemManager itemManager, Zone zone, EquippableItem hull, EntitySettings settings) @@ -1378,6 +1393,10 @@ public abstract class Entity TurnRate = 0; if (!_active) return; + _incomingLocks.Clear(); + _incomingLocks.AddRange(_paintInbox); + _paintInbox.Clear(); + var velocityBefore = Velocity; LimitClamp = float2(0, 0); var hullData = ItemManager.GetData(Hull) as HullData; diff --git a/tests/Aetheria.Shared.Tests/LockWarningTests.cs b/tests/Aetheria.Shared.Tests/LockWarningTests.cs new file mode 100644 index 000000000..f3e208f81 --- /dev/null +++ b/tests/Aetheria.Shared.Tests/LockWarningTests.cs @@ -0,0 +1,258 @@ +using System; +using System.IO; +using System.Linq; +using CultMath; +using GameCult.Caching; +using Xunit; +using static CultMath.math; + +// A painted ship owns a typed incoming-lock warning (cut lock-warning; rulings sensor-stat-set item 4 and +// lock-warning-strength). Every fixture drives the production path: a real LockWeapon on a real ship executes, +// the receiver's own Update publishes. The warning's strength is the emitter's own Lock at the paint. +public sealed class LockWarningTests : IDisposable +{ + private readonly string _root = Path.Combine(Path.GetTempPath(), "aetheria-lockwarn-" + Guid.NewGuid().ToString("N")); + private string Catalog => Path.Combine(_root, "Aetheria.cc"); + private static readonly int2 HardpointCell = new int2(0, 0); + private CultCache _openCache; + private ItemManager _items; + private Zone _zone; + + public LockWarningTests() + { + Directory.CreateDirectory(_root); + using (var cache = AetheriaStores.Open(Catalog, catalogWritable: true)) + { + cache.Upsert(new VerseGrammar { Revision = 1 }); + var hullShape = new Shape(3, 3); + foreach (var cell in hullShape.AllCoordinates) hullShape[cell] = true; + cache.Upsert(new HullData + { + Name = "Skiff", HullType = HullType.Ship, Shape = hullShape, Durability = 1, + Hardpoints = { new HardpointData { Type = HardpointType.Sensors, Position = HardpointCell, Shape = new Shape() } } + }); + cache.Upsert(new GearData + { + Name = "Launcher", Hardpoint = HardpointType.Sensors, Shape = new Shape(), Durability = 1, + Behaviors = + { + new LockWeaponData + { + Count = Const(1), Cooldown = Const(1), + LockSpeed = Const(.6f), SensorImpact = Const(1), LockAngle = Const(30), + DirectionImpact = Const(1), Decay = Const(.1f) + } + } + }); + cache.FlushAsync().Wait(); + } + + _openCache = AetheriaStores.Open(Catalog, catalogWritable: true); + var ledger = new ProvenanceLedger(); + ledger.Lots[1] = new Lot { Origin = new Attributed(), Quality = 1 }; + ledger.Lots[2] = new Lot { Origin = new Attributed(), Quality = 1 }; + _items = new ItemManager(_openCache, ledger, new GameplaySettings + { + DefaultEntitySettings = new EntitySettings(), + Tiers = new[] { new RarityTier { Name = "Common", Quality = .5f, Rarity = 0, Color = new float3(1, 1, 1) } }, + QualityPriceModifier = new ExponentialLerp(), + TargetDetectionInfoThreshold = .5f + }, _ => { }); + _zone = new Zone(_items, new PlanetSettings(), new ZonePack(), new GalaxyZone { Name = "Test Zone", Owner = null }, null); + } + + public void Dispose() + { + _openCache?.Dispose(); + Directory.Delete(_root, true); + } + + private static PerformanceStat Const(float v) => new PerformanceStat { Min = v, Max = v }; + + private Ship NewShip(float3 position, bool launcher) + { + var hullRef = _items.ItemData.RefOf(_items.ItemData.GetByName("Skiff")); + var ship = new Ship(_items, _zone, new EquippableItem { Data = hullRef, Durability = 1, Lot = 1 }, new EntitySettings()) + { Faction = new Faction { Name = "F" + Guid.NewGuid().ToString("N") } }; + if (launcher) + { + var gearRef = _items.ItemData.RefOf(_items.ItemData.GetByName("Launcher")); + Assert.True(ship.TryEquip(new EquippableItem { Data = gearRef, Durability = 1, Lot = 2 }, HardpointCell)); + } + _zone.Entities.Add(ship); + ship.Activate(); + ship.Position = position; + return ship; + } + + private static LockWeapon LauncherOf(Entity e) => + (LockWeapon) e.Equipment.Single().Behaviors.Single(b => b is LockWeapon); + + // The locker targets `target`, hostile to it, aimed straight at it (or along `aim`), then the launcher ticks. + private static LockWeapon Aim(Ship locker, Ship target, float dt, float3? aim = null) + { + locker.SetIff(target, true); + locker.EntityInfoGathered[target] = 1; + locker.SetTarget(target); + locker.Aim = aim ?? normalize(target.Position - locker.Position); + var launcher = LauncherOf(locker); + launcher.Execute(dt); + return launcher; + } + + [Fact] + public void APaintedShipLearnsBearingClassAndStrength() + { + var locker = NewShip(float3(0, 0, 0), launcher: true); + var b = NewShip(float3(3, 0, 4), launcher: false); + + var launcher = Aim(locker, b, .5f); + b.Update(.01f); + + var warning = Assert.Single(b.IncomingLocks); + Assert.Equal(-.6f, warning.Bearing.x, 4); + Assert.Equal(-.8f, warning.Bearing.y, 4); + Assert.Same(locker.Equipment.Single().Data, warning.EmitterClass); + Assert.Equal(.3f, launcher.Lock, 4); // fixture: 0.6 speed * 0.5 s on a dead-on, fully sensed target + Assert.Equal(.3f, warning.Strength, 4); + + // The strength follows the emitter's lock as it grows: a stale or scaled copy would not. + Aim(locker, b, .5f); + b.Update(.01f); + Assert.Equal(.6f, launcher.Lock, 4); + Assert.Equal(.6f, Assert.Single(b.IncomingLocks).Strength, 4); + } + + [Fact] + public void AStrengthAtTheCeilingIsOne() + { + var locker = NewShip(float3(0, 0, 0), launcher: true); + var b = NewShip(float3(0, 0, 9), launcher: false); + + Aim(locker, b, 5f); // saturates the lock + b.Update(.01f); + + Assert.Equal(1f, Assert.Single(b.IncomingLocks).Strength, 4); + } + + [Fact] + public void APaintFromALockerItCannotSeeStillWarns() + { + var locker = NewShip(float3(0, 0, 0), launcher: true); + var b = NewShip(float3(0, 0, 9), launcher: false); + b.EntityInfoGathered[locker] = 0; + Assert.DoesNotContain(locker, b.VisibleEnemies); + + Aim(locker, b, .5f); + b.Update(.01f); + + Assert.Single(b.IncomingLocks); + Assert.DoesNotContain(locker, b.VisibleEnemies); + } + + [Fact] + public void TargetedIsNotPainted() + { + var locker = NewShip(float3(0, 0, 0), launcher: true); + var b = NewShip(float3(0, 0, 9), launcher: false); + var c = NewShip(float3(9, 0, 0), launcher: false); + + // B is targeted but the launcher is aimed at C, far outside LockAngle of B. + locker.SetIff(b, true); + locker.SetIff(c, true); + locker.EntityInfoGathered[b] = 1; + locker.EntityInfoGathered[c] = 1; + locker.SetTarget(b); + locker.Aim = float3(1, 0, 0); + LauncherOf(locker).Execute(.5f); + b.Update(.01f); + c.Update(.01f); + + Assert.Empty(b.IncomingLocks); + Assert.Empty(c.IncomingLocks); // C is aimed at but not the target: nothing paints it either + } + + [Fact] + public void AWarningEndsOneTickAfterItsPaint() + { + var locker = NewShip(float3(0, 0, 0), launcher: true); + var b = NewShip(float3(0, 0, 9), launcher: false); + + var launcher = Aim(locker, b, .5f); + b.Update(.01f); + Assert.Single(b.IncomingLocks); + + // No emission this tick (the launcher item is offline): the warning is gone after B's next Update. + b.Update(.01f); + Assert.Empty(b.IncomingLocks); + + // The locker turns away: the lock decays but is still above zero, and nothing is painted. + Aim(locker, b, .5f); + b.Update(.01f); + Assert.Single(b.IncomingLocks); + locker.Aim = float3(0, 0, -1); + launcher.Execute(.5f); + Assert.True(launcher.Lock > 0f, "fixture: the lock decays, it does not vanish"); + b.Update(.01f); + Assert.Empty(b.IncomingLocks); + } + + [Fact] + public void ARetargetedLockerNoLongerWarnsItsOldTarget() + { + var locker = NewShip(float3(0, 0, 0), launcher: true); + var b = NewShip(float3(0, 0, 9), launcher: false); + var c = NewShip(float3(9, 0, 0), launcher: false); + + var launcher = Aim(locker, b, .5f); + b.Update(.01f); + Assert.Single(b.IncomingLocks); + + Aim(locker, c, .5f); // retarget: aim at C + b.Update(.01f); + c.Update(.01f); + + Assert.Empty(b.IncomingLocks); + Assert.Equal(.3f, Assert.Single(c.IncomingLocks).Strength, 4); // the lock restarted on C + Assert.Equal(.3f, launcher.Lock, 4); + } + + [Fact] + public void EachPainterIsItsOwnWarning() + { + var one = NewShip(float3(0, 0, 0), launcher: true); + var two = NewShip(float3(10, 0, 10), launcher: true); + var b = NewShip(float3(0, 0, 10), launcher: false); + + Aim(one, b, .5f); // 0.3 + Aim(two, b, .25f); // 0.15 + b.Update(.01f); + + Assert.Equal(2, b.IncomingLocks.Count); + var strengths = b.IncomingLocks.Select(w => w.Strength).OrderBy(s => s).ToArray(); + Assert.Equal(.15f, strengths[0], 4); + Assert.Equal(.3f, strengths[1], 4); // never summed to 0.45 + Assert.Equal(2, b.IncomingLocks.Select(w => (w.Bearing.x, w.Bearing.y)).Distinct().Count()); + } + + [Fact] + public void AnInactiveShipHoldsNoWarnings() + { + var locker = NewShip(float3(0, 0, 0), launcher: true); + var b = NewShip(float3(0, 0, 9), launcher: false); + Aim(locker, b, .5f); + b.Update(.01f); + Assert.Single(b.IncomingLocks); + + b.Deactivate(); + Assert.Empty(b.IncomingLocks); + + Aim(locker, b, .5f); // painted while docked or dead + b.Update(.01f); + Assert.Empty(b.IncomingLocks); + + b.Activate(); + b.Update(.01f); + Assert.Empty(b.IncomingLocks); // the paint received while inactive was never stored + } +}