From 4708a71a67e2c2c8ae871d97616b185d243a2078 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Tue, 13 Oct 2026 07:13:55 +0200 Subject: [PATCH] contact-hud: the HUD reads the player's own ContactOf and book; TargetedBy and the spotted blink go Selection is nothing the selected ship can sense: TargetedBy, TargetedByCount, their subscriptions and the music read of them are deleted. Indicator fills, the target panel, the compass, music and the enemy schematic rows now read CurrentEntity's ContactOf / RevealsOf / RevealedSubjects (new, the book's keys); the schematic rows are built by one builder from the book and rebuilt when the revealed set changes. Impact hull effect and projectile hit effects at another ship's position draw only through ActionGameManager.DrawsAt (the player's own ship or a Tracked contact). Builds untested in Unity at this commit; tests NothingReadsSelection and RevealedSubjectsListsTheBook. Co-Authored-By: Claude Opus 5.5 --- Assets/Scenes/ARPG.unity | 2 - Assets/Scripts/Gameplay/ActionGameManager.cs | 74 ++++++++++++------- Assets/Scripts/Gameplay/ImpactPresenter.cs | 3 +- .../Gameplay/Weapons/GuidedProjectile.cs | 4 +- .../Weapons/GuidedProjectileManager.cs | 3 +- Assets/Scripts/Gameplay/Weapons/Projectile.cs | 2 +- Assets/Scripts/ServerShared/Entity.cs | 15 ++-- Assets/Scripts/UI/HUD/SchematicDisplay.cs | 44 ++++++++--- Assets/Scripts/Zone Display/ZoneRenderer.cs | 3 +- .../ContactRevealTests.cs | 56 ++++++++++++++ 10 files changed, 149 insertions(+), 57 deletions(-) diff --git a/Assets/Scenes/ARPG.unity b/Assets/Scenes/ARPG.unity index a11091e71..937bfaa14 100644 --- a/Assets/Scenes/ARPG.unity +++ b/Assets/Scenes/ARPG.unity @@ -16231,8 +16231,6 @@ MonoBehaviour: m_Name: m_EditorClassIdentifier: Settings: {fileID: 11400000, guid: cd2344e2001a37544aab0960c164d4ff, type: 2} - TargetSpottedBlinkFrequency: 20 - TargetSpottedBlinkOffset: -0.25 DeathPostTransitionTime: 1 DeathPost: {fileID: 803284856} HeatstrokePost: {fileID: 1404901906} diff --git a/Assets/Scripts/Gameplay/ActionGameManager.cs b/Assets/Scripts/Gameplay/ActionGameManager.cs index 3fae48b73..ec776af55 100644 --- a/Assets/Scripts/Gameplay/ActionGameManager.cs +++ b/Assets/Scripts/Gameplay/ActionGameManager.cs @@ -113,8 +113,11 @@ public class ActionGameManager : MonoBehaviour //public string StarterShipTemplate = "Longinus"; // The run's credits: seeded from the scenario stage on a new game and from SavedGame on Continue, written by SaveRun. [NonSerialized] public int Credits; - public float TargetSpottedBlinkFrequency = 20; - public float TargetSpottedBlinkOffset = -.25f; + // Presentation scale: the player's own Visibility at which the target panel's VisibilityToTargetFill reads full. + public float OwnSignatureFullScale = 10; + // The music last asked for: Update decides it each frame from what the player's own instruments hold and calls + // PlayMusic only when it changes. + private MusicType? _music; [Header("Postprocessing")] public float DeathPostTransitionTime; @@ -733,6 +736,7 @@ public class ActionGameManager : MonoBehaviour Zone = galaxyZone.Contents; _zoneEntered = true; // the clock forgets its lead between frames, never inside a step PlayMusic(MusicType.Overworld); + _music = MusicType.Overworld; Zone.Log = s => Debug.Log($"Zone: {s}"); @@ -1065,11 +1069,6 @@ public class ActionGameManager : MonoBehaviour foreach (var group in _articulationGroups) group.crosshair.gameObject.SetActive(true); - _shipSubscriptions.Add(CurrentEntity.TargetedByCount.Subscribe(count => - { - PlayMusic(count > 0 ? MusicType.Combat : MusicType.Overworld); - })); - _shipSubscriptions.Add(CurrentEntity.Target.Subscribe(target => { // Clear previous subscriptions related to currently targeted enemy @@ -1264,6 +1263,42 @@ public class ActionGameManager : MonoBehaviour // TODO: SFX: Music } + // What the player's own instruments say about combat, nothing a hostile knows: a hostile tracked, a paint on the + // player, or a remembered hostile the player still has a bearing on (a reveal younger than BearingMemory). + 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; + } + var type = combat ? MusicType.Combat : MusicType.Overworld; + if (_music == type) return; + _music = type; + PlayMusic(type); + } + + // An indicator's fill is how far the player's own sensors have resolved the ship from a track toward identifying + // it (ContactOf.Info over the detection-to-gear span): nothing of what the ship sees of the player. + private void PlaceFill(VisibleTargetIndicator indicator, Entity subject) + { + indicator.Fill.enabled = subject.Active; + if (!subject.Active) return; + var settings = Settings.GameplaySettings; + indicator.Fill.fillAmount = saturate(unlerp(settings.TargetDetectionInfoThreshold, settings.TargetGearInfoThreshold, + CurrentEntity.ContactOf(subject).Info)); + } + + // Whether an effect drawn at `subject`'s true position may be drawn: the player's own ship, or a ship its + // instruments track. The one seam for impact and hit effects (follow-up presentation-gated-on-tracked); a bearing + // alone (Detected without Tracked) places nothing there. No player ship, nothing to gate on. + public static bool DrawsAt(Entity subject) => + Instance == null || Instance.CurrentEntity == null || subject == null || subject == Instance.CurrentEntity || + Instance.CurrentEntity.ContactOf(subject).Tracked; + void Update() { if(!_paused) @@ -1278,29 +1313,13 @@ public class ActionGameManager : MonoBehaviour { indicator.Value.gameObject.SetActive(indicator.Key!=CurrentEntity.Target.Value.Entity); indicator.Value.Place.Target = indicator.Key.Position.ToUnity(); - if (!indicator.Key.Active) - indicator.Value.Fill.enabled = false; - else - { - indicator.Value.Fill.fillAmount = - saturate(indicator.Key.EntityInfoGathered[CurrentEntity] / Settings.GameplaySettings.TargetDetectionInfoThreshold); - indicator.Value.Fill.enabled = - !(indicator.Key.EntityInfoGathered[CurrentEntity] > Settings.GameplaySettings.TargetDetectionInfoThreshold) || - sin(TargetSpottedBlinkFrequency * Time.time) + TargetSpottedBlinkOffset > 0; - } + PlaceFill(indicator.Value, indicator.Key); } foreach (var indicator in _visibleFriendlyIndicators) { indicator.Value.gameObject.SetActive(indicator.Key!=CurrentEntity.Target.Value.Entity); indicator.Value.Place.Target = indicator.Key.Position.ToUnity(); - if (!indicator.Key.Active) - indicator.Value.Fill.enabled = false; - else - { - indicator.Value.Fill.enabled = true; - indicator.Value.Fill.fillAmount = - saturate(indicator.Key.EntityInfoGathered[CurrentEntity] / Settings.GameplaySettings.TargetDetectionInfoThreshold); - } + PlaceFill(indicator.Value, indicator.Key); } var look = Input.Player.Look.ReadValue(); var sensitivity = PlayerSettings.InputSettings.Sensitivity; @@ -1321,13 +1340,14 @@ public class ActionGameManager : MonoBehaviour ship.Turn = _helm.Demand(ship, _viewDirection.xz, Input.Player.Turn.ReadValue()); } + UpdateMusic(); UpdateFireControlDebug(CurrentEntity.Target.Value); var target = CurrentEntity.Target.Value.Entity; if (target != null) { var threshold = Settings.GameplaySettings.TargetDetectionInfoThreshold; - TargetVisibilityFill.fillAmount = lerp(.25f, .75f, (CurrentEntity.EntityInfoGathered[target] - threshold)/(1-threshold)); - VisibilityToTargetFill.fillAmount = lerp(.25f, .75f, target.EntityInfoGathered[CurrentEntity] / threshold); + TargetVisibilityFill.fillAmount = lerp(.25f, .75f, (CurrentEntity.ContactOf(target).Info - threshold)/(1-threshold)); + VisibilityToTargetFill.fillAmount = lerp(.25f, .75f, saturate(CurrentEntity.Visibility / OwnSignatureFullScale)); TargetHitpointsFill.fillAmount = lerp(.25f, .75f, target.Hull.Durability / target.EquippedHull.Data.Durability); TargetShieldsFill.fillAmount = target.Shield == null ? 0 : lerp(.25f, .75f, target.Shield.Progress); } diff --git a/Assets/Scripts/Gameplay/ImpactPresenter.cs b/Assets/Scripts/Gameplay/ImpactPresenter.cs index f8c68c6db..9796ec753 100644 --- a/Assets/Scripts/Gameplay/ImpactPresenter.cs +++ b/Assets/Scripts/Gameplay/ImpactPresenter.cs @@ -5,6 +5,7 @@ using UnityEngine; // How one entity's landed impacts look and feel: the hull reaction at the drawn impact point, and the player's camera // kick. The simulation publishes each impact once on the entity's Capabilities (FireControl); Feedback.Cue decides how // hard it reads and whether the camera feels it, DrawAhead.Carried decides where it is drawn; this decides nothing. +// The hull effect is drawn only where the player's instruments place the ship (ActionGameManager.DrawsAt). // The shield ripple is ShieldManager's. Everything here is display-only and runs in real time. public class ImpactPresenter : MonoBehaviour, IAbsorbPresenter { @@ -23,7 +24,7 @@ public class ImpactPresenter : MonoBehaviour, IAbsorbPresenter ActionGameManager.PlayerSettings.GraphicsSettings.ScreenShake, feedback.IntensityScale); var point = DrawAhead.Carried(Entity, cue.Position, ActionGameManager.Clock.Lead).ToUnity(); - if (e.Layer == AbsorbLayer.Hull && feedback.HullEffect != null) + if (e.Layer == AbsorbLayer.Hull && feedback.HullEffect != null && ActionGameManager.DrawsAt(Entity)) { _live.RemoveAll(live => live == null || !live.gameObject.activeSelf); var effect = feedback.HullEffect.Instantiate(); diff --git a/Assets/Scripts/Gameplay/Weapons/GuidedProjectile.cs b/Assets/Scripts/Gameplay/Weapons/GuidedProjectile.cs index 26a6db650..7e02d2613 100644 --- a/Assets/Scripts/Gameplay/Weapons/GuidedProjectile.cs +++ b/Assets/Scripts/Gameplay/Weapons/GuidedProjectile.cs @@ -87,7 +87,7 @@ public class GuidedProjectile : MonoBehaviour // The resolution. A round that detonated (a hit, or a burst) bursts now, at the simulation's burst point when // it has one; a round the simulation resolved without a detonation (a contact or delayed round that missed, // one whose target is gone) just falls away, with no explosion to show. - public void Resolve(bool detonated, Vector3? point) + public void Resolve(bool detonated, Vector3? point, bool showHitEffect) { // A round that already faded out (overshot, out of flight) still shows the simulation's detonation: only a // round that has been killed is done. The fade is stopped so it cannot Kill the round a second time. @@ -102,7 +102,7 @@ public class GuidedProjectile : MonoBehaviour // short of it), and is skipped when the round never knew where its target was. Vector3? effectAt = point ?? (TargetPosition != null ? TargetPosition() : Target ? Target.position : (Vector3?) null); if (point.HasValue) transform.position = point.Value; - if (HitEffect != null && effectAt.HasValue) + if (HitEffect != null && showHitEffect && effectAt.HasValue) { var ht = HitEffect.Instantiate(); ht.position = effectAt.Value; diff --git a/Assets/Scripts/Gameplay/Weapons/GuidedProjectileManager.cs b/Assets/Scripts/Gameplay/Weapons/GuidedProjectileManager.cs index b325b4fd8..98ce5594c 100644 --- a/Assets/Scripts/Gameplay/Weapons/GuidedProjectileManager.cs +++ b/Assets/Scripts/Gameplay/Weapons/GuidedProjectileManager.cs @@ -17,7 +17,8 @@ public class GuidedProjectileManager : InstantWeaponEffectManager var bindings = new CompositeDisposable(); zone.ShotCommitted.Where(o => o.ShotId == shotId && o.HasBurstPoint).Subscribe(o => p.BurstAt(At(o))).AddTo(bindings); zone.ShotResolved.Where(o => o.ShotId == shotId) - .Subscribe(o => p.Resolve(o.Result == ShotResult.Hit || o.Result == ShotResult.Burst, o.HasBurstPoint ? At(o) : (Vector3?) null)) + .Subscribe(o => p.Resolve(o.Result == ShotResult.Hit || o.Result == ShotResult.Burst, o.HasBurstPoint ? At(o) : (Vector3?) null, + ActionGameManager.DrawsAt(o.Target))) .AddTo(bindings); // The binding outlives a fade-out until the shot's own arrival (the moment the simulation resolves it), plus a // second of margin; a shot no longer pending has nothing left to resolve. diff --git a/Assets/Scripts/Gameplay/Weapons/Projectile.cs b/Assets/Scripts/Gameplay/Weapons/Projectile.cs index 66a444d89..86f9a46e5 100644 --- a/Assets/Scripts/Gameplay/Weapons/Projectile.cs +++ b/Assets/Scripts/Gameplay/Weapons/Projectile.cs @@ -68,7 +68,7 @@ public class Projectile : MonoBehaviour _known = outcome; if (outcome.Result == ShotResult.Miss) return; Draw(); - if (HitEffect != null) HitEffect.Instantiate().position = transform.position; + if (HitEffect != null && ActionGameManager.DrawsAt(outcome.Target)) HitEffect.Instantiate().position = transform.position; Finish(); } diff --git a/Assets/Scripts/ServerShared/Entity.cs b/Assets/Scripts/ServerShared/Entity.cs index dc944d9e5..b4ec92b32 100644 --- a/Assets/Scripts/ServerShared/Entity.cs +++ b/Assets/Scripts/ServerShared/Entity.cs @@ -192,9 +192,7 @@ public abstract class Entity public Subject HeatstrokeDeath = new Subject(); public Subject HypothermiaRisk = new Subject(); public Subject HypothermiaDeath = new Subject(); - public Subject TargetedBy = new Subject(); - public ReactiveProperty TargetedByCount = new ReactiveProperty(0); - // TargetedBy counts selection, which emits nothing; a warning comes only from a paint. A search paint is a ping + // Selection tells the selected ship nothing: a warning comes only from a paint. A search paint is a ping // sweeping this ship (Sensor.Execute); a track paint is a lock (LockWeapon.Execute). Each reports into the // inbox the tick it paints; PublishInbox publishes the inbox once, so a warning lives exactly one tick after // its last paint. @@ -308,13 +306,6 @@ public abstract class Entity { if (Target.Value.Entity == remove.Value) SetTarget(TargetRef.None); })); - // Only an entity is ever targeted by: a chunk has no one to tell. - _subscriptions.Add(Target.Subscribe(target => target.Entity?.TargetedBy.OnNext(this))); - _subscriptions.Add(TargetedBy.Subscribe(enemy => - { - TargetedByCount.Value++; - enemy.Target.Where(t => t.Entity != this).Take(1).Subscribe(_ => TargetedByCount.Value--); - })); // _subscriptions.Add(EntityInfoGathered.ObserveReplace().Subscribe(replace => @@ -947,6 +938,10 @@ public abstract class Entity if (book.Count == 0) _reveals.Remove(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 RevealedSubjects => _reveals.Keys; + // 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, diff --git a/Assets/Scripts/UI/HUD/SchematicDisplay.cs b/Assets/Scripts/UI/HUD/SchematicDisplay.cs index d6402e3f6..b3dc8662e 100644 --- a/Assets/Scripts/UI/HUD/SchematicDisplay.cs +++ b/Assets/Scripts/UI/HUD/SchematicDisplay.cs @@ -69,13 +69,11 @@ public class SchematicDisplay : MonoBehaviour { _enemy = player != null; _player = player; - if (_schematicItems != null) - foreach (var item in _schematicItems) - { - item.ListElement.GetComponent().ReturnToPool(); - } + ReturnRows(); _entity = entity; + if (VisibilityLabel) + VisibilityLabel.gameObject.SetActive(!_enemy); if (!_enemy) { _cockpit = entity.GetBehavior(); @@ -95,11 +93,27 @@ public class SchematicDisplay : MonoBehaviour CargoTemperatureLabel.text = "N/A"; } - // Cut 2 (docs/fire-control-cut.md): an enemy schematic shows only what `player` has actually - // resolved (FireControl.IsRevealed) -- the same predicate the selection writer and the AI use. The - // player's own schematic is unfiltered; there is nothing to reveal about your own ship. - _schematicItems = entity.Equipment - .Where(x => x.Behaviors.Any(b => b.Data is WeaponData) && (!_enemy || FireControl.IsRevealed(player, x))) + BuildRows(_enemy ? RevealedWeapons() : entity.Equipment.Where(IsWeapon)); + } + + private static bool IsWeapon(EquippedItem item) => item.Behaviors.Any(b => b.Data is WeaponData); + + // An enemy schematic lists what `_player` has seen the ship's weapons do: the weapon items of the player's book on it, + // in book order (ruling reveal-ends-on-perception), never the ship's live gear. The player's own schematic is its + // equipment; there is nothing to reveal about your own ship. + private IEnumerable RevealedWeapons() => _player.RevealsOf(_entity).Select(r => r.Item).Where(IsWeapon); + + private void ReturnRows() + { + if (_schematicItems == null) return; + foreach (var item in _schematicItems) + item.ListElement.GetComponent().ReturnToPool(); + _schematicItems = null; + } + + private void BuildRows(IEnumerable items) + { + _schematicItems = items .Select(x => new SchematicDisplayItem { Item = x, @@ -181,6 +195,14 @@ public class SchematicDisplay : MonoBehaviour // DistanceLabel.text = $"{(int)length(_entity.Position - _player.Position)}"; // } + // A reveal gained or ended after selection changes the rows with no event to hook: rebuild when the + // revealed weapon set differs from the rows' items. + if (_enemy && !RevealedWeapons().SequenceEqual(_schematicItems.Select(x => x.Item))) + { + ReturnRows(); + BuildRows(RevealedWeapons()); + } + // Cut 2 (docs/fire-control-cut.md): marks whichever row is the player's current aim point. // Re-checked every frame (not just on ShowShip) because ResolvedTargetItem can drop on its own // as info decays, with no event to hook. @@ -188,7 +210,7 @@ public class SchematicDisplay : MonoBehaviour foreach (var x in _schematicItems) x.ListElement.SetSelected(x.Item == _player.ResolvedTargetItem); - if(VisibilityLabel) + if(VisibilityLabel && !_enemy) VisibilityLabel.text = ((int)_entity.Visibility).ToString(); if(HullDurabilityFill) diff --git a/Assets/Scripts/Zone Display/ZoneRenderer.cs b/Assets/Scripts/Zone Display/ZoneRenderer.cs index d39e0d2ea..3a27a2f35 100644 --- a/Assets/Scripts/Zone Display/ZoneRenderer.cs +++ b/Assets/Scripts/Zone Display/ZoneRenderer.cs @@ -571,8 +571,7 @@ public class ZoneRenderer : MonoBehaviour var distance = length(difference); entityInstance.CompassIcon.gameObject.SetActive( - PerspectiveEntity.EntityInfoGathered.ContainsKey(entityInstance.Entity) && - PerspectiveEntity.EntityInfoGathered[entityInstance.Entity] > Settings.GameplaySettings.TargetDetectionInfoThreshold && + PerspectiveEntity.ContactOf(entityInstance.Entity).Tracked && distance > _minimapDistance); entityInstance.CompassIcon.transform.rotation = Quaternion.Euler(0, 0, Mathf.Atan2(difference.y, difference.x) * Mathf.Rad2Deg - 90); } diff --git a/tests/Aetheria.Shared.Tests/ContactRevealTests.cs b/tests/Aetheria.Shared.Tests/ContactRevealTests.cs index a3bc68be9..21f7d1d27 100644 --- a/tests/Aetheria.Shared.Tests/ContactRevealTests.cs +++ b/tests/Aetheria.Shared.Tests/ContactRevealTests.cs @@ -1223,6 +1223,62 @@ public sealed partial class ContactRevealTests : IDisposable Assert.Null(PathFrom(o, held)); } + // Cut contact-hud: selection tells the selected ship nothing. A ship that selects another, which is neither painted + // by it nor sees it, leaves no fact on the selected one: no paint in its inbox, no contact, no reveal. + [Fact] + public void NothingReadsSelection() + { + var selector = Add(float3(0, 0, 200), new[] { "Gun" }); + var selected = Add(float3(0, 0, 0)); // deaf and unseen: no sensor, nothing it could hear + selector.SetTarget(selected); + Assert.Same(selected, selector.Target.Value.Entity); // fixture: it is selected + + for (var i = 0; i < 20; i++) Tick(.1f); + + Assert.Empty(selected.IncomingPaints); + Assert.False(selected.ContactOf(selector).Detected); + Assert.Empty(selected.RevealedSubjects); + } + + // The one list of remembered contacts: the subjects whose reveals the observer holds. A tracked subject with no + // reveal is not in it; a revealed subject that leaves unseen stays until its bearing is BearingMemory old, then + // goes; Deactivate empties it. + [Fact] + public void RevealedSubjectsListsTheBook() + { + var e = Add(float3(0, 0, 100), new[] { "Gun" }); + var tracked = Add(float3(50, 0, 0)); + var o = Add(float3(0, 0, 0), new[] { "Eye" }); + var gun = Guns(e)[0]; + o.EntityInfoGathered[tracked] = 1f; + Assert.True(o.ContactOf(tracked).Tracked); // fixture: tracked, no reveal + Assert.Empty(o.RevealedSubjects); + + Fire(e, gun); + Assert.Same(e, Assert.Single(o.RevealedSubjects)); + + 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.Entities.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.RevealedSubjects.Contains(e)); // exactly while the bearing is remembered + } + Assert.True(lapsed, "fixture: the bearing lapsed"); + Assert.Empty(o.RevealedSubjects); + + var q = Add(float3(40, 0, 100), new[] { "Gun" }); + Fire(q, Guns(q)[0]); + Assert.Same(q, Assert.Single(o.RevealedSubjects)); + o.Deactivate(); + Assert.Empty(o.RevealedSubjects); + } + // 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) {