diff --git a/Assets/Scripts/Gameplay/ActionGameManager.cs b/Assets/Scripts/Gameplay/ActionGameManager.cs index 0877a4a03..6d1b23dfb 100644 --- a/Assets/Scripts/Gameplay/ActionGameManager.cs +++ b/Assets/Scripts/Gameplay/ActionGameManager.cs @@ -441,21 +441,21 @@ public class ActionGameManager : MonoBehaviour Input.Player.TargetNext.performed += context => CurrentEntity.TargetNext(); Input.Player.TargetPrevious.performed += context => CurrentEntity.TargetPrevious(); - // Cut 2 (docs/fire-control-cut.md): cycles the aim point among the current target's revealed - // subsystems -- decides nothing itself, only calls the one writer (TrySelectTargetItem), same - // predicate (FireControl.IsRevealed) the AI path uses. Entity-only: a chunk has no subsystems. + // Cut 2 (docs/fire-control-cut.md): cycles the aim point among the current target's located + // subsystems -- decides nothing itself, only calls the one writer (TrySelectTargetItem), gated on the + // same predicate (FireControl.IsLocated) the writer reads. Entity-only: a chunk has no subsystems. Input.Player.CycleTargetItem.performed += context => { var target = CurrentEntity.Target.Value.Entity; if (target == null) return; - var revealed = target.Equipment.Where(x => FireControl.IsRevealed(CurrentEntity, x)).ToArray(); - if (revealed.Length == 0) + var located = target.Equipment.Where(x => FireControl.IsLocated(CurrentEntity, x)).ToArray(); + if (located.Length == 0) { CurrentEntity.TrySelectTargetItem(null); return; } - var currentItemIndex = Array.IndexOf(revealed, CurrentEntity.ResolvedTargetItem); - CurrentEntity.TrySelectTargetItem(revealed[(currentItemIndex + 1) % revealed.Length]); + var currentItemIndex = Array.IndexOf(located, CurrentEntity.ResolvedTargetItem); + CurrentEntity.TrySelectTargetItem(located[(currentItemIndex + 1) % located.Length]); }; #endregion diff --git a/Assets/Scripts/ServerShared/Entity.cs b/Assets/Scripts/ServerShared/Entity.cs index 9c045eb8e..226204133 100644 --- a/Assets/Scripts/ServerShared/Entity.cs +++ b/Assets/Scripts/ServerShared/Entity.cs @@ -71,9 +71,9 @@ public abstract class Entity // Cut 2 (docs/fire-control-cut.md): the stored aim-point slot. Written only by TrySelectTargetItem // below, and nulled whenever Target changes (subscribed in Activate). Do not read this field directly - // outside Entity -- read ResolvedTargetItem, which re-validates on every call, so decay (or the item - // leaving the target) drops the aim point the moment it is no longer earned, with no loop that has to - // notice and clear it. + // outside Entity -- read ResolvedTargetItem, which re-validates on every call (FireControl.IsLocated), so decay + // (or the item leaving the target, or being destroyed) drops the aim point the moment it is no longer earned, + // with no loop that has to notice and clear it. public ReactiveProperty TargetItem = new ReactiveProperty((EquippedItem)null); // Where the pilot points: reticle, lock cone, tractor, guided rounds, fire direction. Steers nothing; the @@ -408,8 +408,9 @@ public abstract class Entity } // Cut 2 (docs/fire-control-cut.md): the only writer of TargetItem. Accepts null (clearing the aim - // point) or an item belonging to the current Target that FireControl.IsRevealed resolves true for this - // entity as observer. Returns whether the write took effect; a rejected non-null selection leaves + // point) or an item belonging to the current Target that FireControl.IsLocated resolves true for this + // entity as observer (an item it saw act, or one on a mount of the hull it believes the target to be). + // Returns whether the write took effect; a rejected non-null selection leaves // TargetItem unchanged. public bool TrySelectTargetItem(EquippedItem item) { @@ -420,7 +421,7 @@ public abstract class Entity } // Mining Cut 3: entity-only. A chunk has no items, so an item never belongs to a chunk target. - if (Target.Value.Entity == null || item.Entity != Target.Value.Entity || !FireControl.IsRevealed(this, item)) + if (Target.Value.Entity == null || item.Entity != Target.Value.Entity || !FireControl.IsLocated(this, item)) return false; TargetItem.Value = item; @@ -428,10 +429,10 @@ public abstract class Entity } // The read path for TargetItem (see the field's own comment): null once the aimed item is no longer - // revealed to this entity, or no longer belongs to the current Target, even though nothing wrote + // located by this entity, or no longer belongs to the current Target, even though nothing wrote // TargetItem.Value at the moment that became true. Re-checked on every call; never cached. public EquippedItem ResolvedTargetItem => - TargetItem.Value != null && Target.Value.Entity == TargetItem.Value.Entity && FireControl.IsRevealed(this, TargetItem.Value) + TargetItem.Value != null && Target.Value.Entity == TargetItem.Value.Entity && FireControl.IsLocated(this, TargetItem.Value) ? TargetItem.Value : null; @@ -1058,12 +1059,23 @@ public abstract class Entity // The rows of `subject`'s schematic as this ship reads it: its own weapons when `subject` is this ship (each row its // own data and live range, nothing to doubt about your own ship), otherwise the revealed weapons (each row the claimed - // class and that design's rated range) then the revealed gear (each row the claimed class, no range). The one owner of - // which rows, which class and which range an enemy schematic shows; the Unity SchematicDisplay renders it. - public IEnumerable SchematicRows(Entity subject) => - subject == this - ? subject.Equipment.Where(i => i.Behaviors.Any(b => b.Data is WeaponData)).Select(i => new SchematicRow(i, i.Data, true)) - : RevealedWeapons(subject).Concat(RevealedGear(subject)).Select(r => new SchematicRow(r.Item, r.Claimed, false)); + // class and that design's rated range) then the revealed gear (each row the claimed class, no range), then one mount + // row for each mount of the hull this ship believes `subject` to be (LayoutOf) that no claimed-item row sits on + // (ruling quiet-gear-identifiable-hull-layout-known). The one owner of which rows, which class and which range an + // enemy schematic shows; the Unity SchematicDisplay renders it. + public IEnumerable SchematicRows(Entity subject) + { + if (subject == this) + return subject.Equipment.Where(i => i.Behaviors.Any(b => b.Data is WeaponData)).Select(i => new SchematicRow(i, i.Data, true)); + var claimed = RevealedWeapons(subject).Concat(RevealedGear(subject)).Select(r => new SchematicRow(r.Item, r.Claimed, false)).ToList(); + return claimed.Concat(LayoutOf(subject) + .Where(h => !claimed.Any(r => r.Item.Hardpoint == h)) + .Select(h => new SchematicRow(h))); + } + + // Where `subject`'s mounts are as this ship believes them: the hull it is identified as (Contact.Profile, the claimed + // hull), never the subject's own HullData; empty below identification. Derived on every read, nothing stored. + public IReadOnlyList LayoutOf(Entity subject) => ContactOf(subject).Profile?.Hardpoints ?? (IReadOnlyList) Array.Empty(); // Whether this observer's book for `subject` holds `item`: the book's membership, no live gear read. internal bool HasReveal(Entity subject, EquippedItem item) => diff --git a/Assets/Scripts/ServerShared/FireControl.cs b/Assets/Scripts/ServerShared/FireControl.cs index 9ce4e55ce..041c6b2ff 100644 --- a/Assets/Scripts/ServerShared/FireControl.cs +++ b/Assets/Scripts/ServerShared/FireControl.cs @@ -156,10 +156,25 @@ public static class FireControl // quiet-gear-identifiable-hull-layout-known, reveal-ends-on-perception): `observer` can aim at `item` when its own // book holds a reveal of it (Entity.HasReveal: it saw the item act) and the item is still fitted to its ship and // not destroyed. The book says what was seen; the live item says whether it can still be aimed at. Derived on - // every read, never cached; watching a ship reveals no gear. + // every read, never cached; watching a ship reveals no gear. Any aiming choice that reads an item's class or + // stats (Combat's target-item choice, its dps) asks this; the selection gate is IsLocated. public static bool IsRevealed(Entity observer, EquippedItem item) => !item.EquippableItem.IsDestroyed && item.Entity.Equipment.Contains(item) && observer.HasReveal(item.Entity, item); + // The selection gate (ruling quiet-gear-identifiable-hull-layout-known): `observer` can aim at `item` when it has seen + // the item act (IsRevealed's question) or the item sits on a mount of the hull the observer believes its ship to be + // (Entity.LayoutOf, by reference: the believed hull's HardpointData are the true item's only while the belief is the + // truth, so under a hull lie no gear is located and the ship stays targetable, ruling ew-aim-under-hull-lie-ruled). + // Locating reads no class or stats. A Tool item sits on no mount, so only an act locates it. + public static bool IsLocated(Entity observer, EquippedItem item) => + !item.EquippableItem.IsDestroyed && item.Entity.Equipment.Contains(item) && + (observer.HasReveal(item.Entity, item) || item.Hardpoint != null && observer.LayoutOf(item.Entity).Contains(item.Hardpoint)); + + // Whether the aim at `aimed` is at its mount (located, not revealed): the aim point is then the mount's cells, not + // the cells of an item the observer has not learned the size of. + public static bool AimsAtMount(Entity observer, EquippedItem aimed) => + aimed != null && aimed.Hardpoint != null && !IsRevealed(observer, aimed); + // Cut 2, Q4: the stats an entity actually fires with. A working targeting system (equipped, online -- // not destroyed, not shut down) supplies its own resolved stats; anything else falls back to the // authored unaided figures, which make aiming without one a last resort rather than an alternative. @@ -337,7 +352,7 @@ public static class FireControl Silhouette sil; try { - sil = Silhouette(target, targetHull, source.ResolvedTargetItem, bearing, precision, buffer); + sil = Silhouette(target, targetHull, source.ResolvedTargetItem, bearing, precision, buffer, AimsAtMount(source, source.ResolvedTargetItem)); var settings = source.ItemManager.GameplaySettings; pSpread = PSpread(weapon.Spread, sil.Span, range, settings.SchematicCellSize); } @@ -723,6 +738,7 @@ public static class FireControl Target = engaged, Weapon = item, Aimed = source.ResolvedTargetItem, + AimAtMount = AimsAtMount(source, source.ResolvedTargetItem), Damage = damageOverride ?? weapon.Damage, FireMinRange = weapon.MinRange, FireEffectiveRange = weapon.EffectiveRange, @@ -895,7 +911,7 @@ public static class FireControl var bearing = Bearing(shot.Target, shot.TravelDirection); var hullData = shot.Target.ItemManager.GetData(shot.Target.Hull) as HullData; - sil = Silhouette(shot.Target, hullData, shot.Aimed, bearing, shot.Precision); + sil = Silhouette(shot.Target, hullData, shot.Aimed, bearing, shot.Precision, null, shot.AimAtMount); var settings = shot.Target.ItemManager.GameplaySettings; return p * PSpread(shot.Spread, sil.Span, shot.FireRange, settings.SchematicCellSize) * sil.POnHull; } @@ -1763,11 +1779,12 @@ public static class FireControl private static float Sigma(float precision) => max(1f / max(precision, 1e-3f), SigmaFloor); // The aim point a silhouette is centred on (R10/Q6: the same frozen Aimed both HitProbability and Fire - // read) -- the aimed item's own cell centroid when it currently occupies cells on the target, else the + // read) -- the aimed item's own cell centroid when it currently occupies cells on the target (its mount's cells + // when the aim was frozen at the mount, atMount: the shooter has not learned what is fitted there), else the // hull's own centre of mass. One path; the uniform-random branch Cut 6d replaced stays deleted. - private static float2 ResolveAimPoint(Entity target, HullData hullData, EquippedItem aimed) + private static float2 ResolveAimPoint(Entity target, HullData hullData, EquippedItem aimed, bool atMount) { - var aimedCells = aimed != null ? CellsOf(target, aimed) : null; + var aimedCells = aimed == null ? null : atMount && aimed.Hardpoint != null ? MountCells(aimed.Hardpoint) : CellsOf(target, aimed); if (aimedCells != null && aimedCells.Length > 0) return aimedCells.Aggregate(float2.zero, (total, c) => total + (float2) c) / aimedCells.Length; return hullData.Shape.CenterOfMass; @@ -1787,7 +1804,7 @@ public static class FireControl // call that follows, so it still passes null and gets an owned allocation. A caller-supplied buffer may be // larger than coords.Length (ArrayPool rents "at least"), so every loop below is bounded by coords.Length, // never buffer.Length. - public static Silhouette Silhouette(Entity target, HullData hull, EquippedItem aimed, float2 bearing, float precision, Interval[] buffer = null) + public static Silhouette Silhouette(Entity target, HullData hull, EquippedItem aimed, float2 bearing, float precision, Interval[] buffer = null, bool atMount = false) { var ell = Lateral(bearing); var coords = hull.Shape.Coordinates; @@ -1809,7 +1826,7 @@ public static class FireControl } var sigma = Sigma(precision); - var aimPoint = ResolveAimPoint(target, hull, aimed); + var aimPoint = ResolveAimPoint(target, hull, aimed, atMount); var a = dot(aimPoint, ell); var pOnHull = 0f; @@ -1964,6 +1981,10 @@ public static class FireControl exit = min(exit, max(t1, t2)); } + // The hull-schematic cells a hardpoint covers, laid as Entity builds its Hardpoints[,] grid. + private static int2[] MountCells(HardpointData hardpoint) => + hardpoint.Shape.Coordinates.Select(c => hardpoint.Position + c).ToArray(); + // The cells of the target's hull schematic actually occupied by `item` -- GearOccupancy is the one source // of truth for where an equipped item's footprint lands, the same table Detonate and ApplyPooled read. private static int2[] CellsOf(Entity target, EquippedItem item) @@ -1988,6 +2009,9 @@ public struct PendingShot public Entity Target; public EquippedItem Weapon; public EquippedItem Aimed; + // Whether the aim was at Aimed's mount rather than the item (FireControl.AimsAtMount at Fire), frozen with it: a + // reveal between Fire and Commit does not move where this shot was aimed. + public bool AimAtMount; // The frozen payload (R10, Q6): the gun's own stats at the instant the trigger was pulled. Nothing // downstream re-evaluates any of these. diff --git a/Assets/Scripts/ServerShared/Sensing.cs b/Assets/Scripts/ServerShared/Sensing.cs index 526a8db23..fe6d1e78c 100644 --- a/Assets/Scripts/ServerShared/Sensing.cs +++ b/Assets/Scripts/ServerShared/Sensing.cs @@ -127,10 +127,13 @@ public readonly struct Reveal // on the viewer's own ship, the live gear. `Claimed` is the class the row shows. The range label is the live range on // the viewer's own ship and the claimed design's rated range on any other; a row whose claimed class is not a weapon has // no range. The viewer never sees a target item's quality, wear or power. A renderer shows these and decides nothing. +// A mount row (`Mount` set, `Item` and `Claimed` null) is a hardpoint of the hull the viewer believes the ship to be, with +// nothing of the ship's claimed on it: where something is, not what. public readonly struct SchematicRow { public readonly EquippedItem Item; public readonly EquippableItemData Claimed; + public readonly HardpointData Mount; private readonly bool _own; internal SchematicRow(EquippedItem item, EquippableItemData claimed, bool own) @@ -140,12 +143,21 @@ public readonly struct SchematicRow _own = own; } + internal SchematicRow(HardpointData mount) + { + Mount = mount; + } + // What a renderer compares to know the rows changed. Range is a reading, not part of the key. - public (EquippedItem, EquippableItemData) Key => (Item, Claimed); + public (EquippedItem, EquippableItemData, HardpointData) Key => (Item, Claimed, Mount); + + // Whether this row is the one `aimed` points at: a claimed-item row by its item, a mount row by the mount the aimed + // item sits on (an aimed item with a claimed-item row has no mount row). + public bool Marks(EquippedItem aimed) => Item != null ? Item == aimed : aimed != null && aimed.Hardpoint == Mount; public float? Range => _own ? Item.Behaviors.OfType().First().Range - : Claimed.Behaviors.OfType().FirstOrDefault()?.RatedRange; + : Claimed?.Behaviors.OfType().FirstOrDefault()?.RatedRange; } // A subject's removal from a zone as the zone recorded it (death, dock, warp, any removal): where it was and how diff --git a/Assets/Scripts/UI/HUD/SchematicDisplay.cs b/Assets/Scripts/UI/HUD/SchematicDisplay.cs index 1ac5a23a7..9e2f6d9f1 100644 --- a/Assets/Scripts/UI/HUD/SchematicDisplay.cs +++ b/Assets/Scripts/UI/HUD/SchematicDisplay.cs @@ -123,7 +123,9 @@ public class SchematicDisplay : MonoBehaviour .ToArray(); foreach (var x in _schematicItems) { - if(x.Row.Claimed is WeaponItemData weaponItemData) + if (x.Row.Mount != null) + x.ListElement.ShowGear(x.Row.Mount.Type, null); + else if(x.Row.Claimed is WeaponItemData weaponItemData) x.ListElement.ShowWeapon(weaponItemData); else x.ListElement.ShowGear(x.Row.Claimed.HardpointType, x.Row.Claimed.Name); @@ -208,7 +210,7 @@ public class SchematicDisplay : MonoBehaviour // as info decays, with no event to hook. if (_enemy && _schematicItems != null) foreach (var x in _schematicItems) - x.ListElement.SetSelected(x.Row.Item == _player.ResolvedTargetItem); + x.ListElement.SetSelected(x.Row.Marks(_player.ResolvedTargetItem)); if(VisibilityLabel && !_enemy) VisibilityLabel.text = ((int)_entity.Visibility).ToString();