Lock warning: a painted ship owns a typed incoming-lock warning carrying the lock's strength
LockWeapon.Execute paints its target with its own Lock (the one value the outgoing indicator also reads); Entity publishes the one-tick inbox as IncomingLocks; the HUD shows one incoming indicator on the strongest warning with the outgoing indicator's curves. Unity HUD code not compiled here; tests not yet run at this commit. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
0caf1fa525
commit
e5cfc0d676
4 changed files with 320 additions and 0 deletions
|
|
@ -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<Entity, VisibleTargetIndicator> _visibleHostileIndicators = new Dictionary<Entity, VisibleTargetIndicator>();
|
||||
private Dictionary<Entity, VisibleTargetIndicator> _visibleFriendlyIndicators = new Dictionary<Entity, VisibleTargetIndicator>();
|
||||
private List<IDisposable> _shipSubscriptions = new List<IDisposable>();
|
||||
|
|
@ -999,6 +1002,8 @@ public class ActionGameManager : MonoBehaviour
|
|||
|
||||
if(_lockingIndicators!=null) foreach(var (_, indicator, _) in _lockingIndicators)
|
||||
indicator.GetComponent<Prototype>().ReturnToPool();
|
||||
if(_incomingLockIndicator.indicator != null) _incomingLockIndicator.indicator.GetComponent<Prototype>().ReturnToPool();
|
||||
_incomingLockIndicator = default;
|
||||
DisablePlayerInput();
|
||||
Cursor.lockState = CursorLockMode.None;
|
||||
GameplayUI.gameObject.SetActive(false);
|
||||
|
|
@ -1142,6 +1147,10 @@ public class ActionGameManager : MonoBehaviour
|
|||
var i = LockIndicator.Instantiate<PlaceUIElementWorldspace>();
|
||||
return (x, i, i.GetComponent<Rotate>());
|
||||
}).ToArray();
|
||||
|
||||
var incoming = LockIndicator.Instantiate<PlaceUIElementWorldspace>();
|
||||
incoming.gameObject.SetActive(false);
|
||||
_incomingLockIndicator = (incoming, incoming.GetComponent<Rotate>());
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -173,6 +173,12 @@ public abstract class Entity
|
|||
public Subject<Unit> HypothermiaDeath = new Subject<Unit>();
|
||||
public Subject<Entity> TargetedBy = new Subject<Entity>();
|
||||
public ReactiveProperty<int> TargetedByCount = new ReactiveProperty<int>(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<LockWarning> _paintInbox = new List<LockWarning>();
|
||||
private readonly List<LockWarning> _incomingLocks = new List<LockWarning>();
|
||||
public IReadOnlyList<LockWarning> IncomingLocks => _incomingLocks;
|
||||
public ReactiveProperty<SecurityLevel> CurrentSecurityLevel = new ReactiveProperty<SecurityLevel>(SecurityLevel.Open);
|
||||
public ReadOnlyReactiveProperty<bool> 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;
|
||||
|
|
|
|||
258
tests/Aetheria.Shared.Tests/LockWarningTests.cs
Normal file
258
tests/Aetheria.Shared.Tests/LockWarningTests.cs
Normal file
|
|
@ -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<ItemData>(_items.ItemData.GetByName<HullData>("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<ItemData>(_items.ItemData.GetByName<GearData>("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
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue