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) {