using System.Diagnostics; using System.Globalization; using System.Linq; using System.Text; using Content.Shared.Eye.Blinding.Components; using Content.Shared.Ghost; using Content.Shared.Interaction; using Content.Shared.Mobs.Components; using Content.Shared.Mobs.Systems; using JetBrains.Annotations; using Robust.Shared.Containers; using Robust.Shared.Map; using Robust.Shared.Physics; using Robust.Shared.Utility; using static Content.Shared.Interaction.SharedInteractionSystem; namespace Content.Shared.Examine { public abstract partial class ExamineSystemShared : EntitySystem { [Dependency] private readonly OccluderSystem _occluder = default!; [Dependency] private readonly SharedTransformSystem _transform = default!; [Dependency] private readonly SharedContainerSystem _containerSystem = default!; [Dependency] private readonly SharedInteractionSystem _interactionSystem = default!; [Dependency] protected readonly MobStateSystem MobStateSystem = default!; public const float MaxRaycastRange = 100; /// /// Examine range to use when the examiner is in critical condition. /// /// /// Detailed examinations are disabled while incapactiated. Ideally this should just be set equal to the /// radius of the crit overlay that blackens most of the screen. The actual radius of that is defined /// in a shader sooo... eh. /// public const float CritExamineRange = 1.3f; /// /// Examine range to use when the examiner is dead. See . /// public const float DeadExamineRange = 0.75f; public const float ExamineRange = 16f; protected const float ExamineDetailsRange = 3f; protected const float ExamineBlurrinessMult = 2.5f; private EntityQuery _ghostQuery; /// /// Creates a new examine tooltip with arbitrary info. /// public abstract void SendExamineTooltip(EntityUid player, EntityUid target, FormattedMessage message, bool getVerbs, bool centerAtCursor); public bool IsInDetailsRange(EntityUid examiner, EntityUid entity) { if (IsClientSide(entity)) return true; // Ghosts can see everything. if (_ghostQuery.HasComp(examiner)) return true; // check if the mob is in critical or dead if (MobStateSystem.IsIncapacitated(examiner)) return false; if (!InRangeUnOccluded(examiner, entity, ExamineDetailsRange)) return false; // Is the target hidden in a opaque locker or something? Currently this check allows players to examine // their organs, if they can somehow target them. Really this should be with userSeeInsideSelf: false, and a // separate check for if the item is in their inventory or hands. if (_containerSystem.IsInSameOrTransparentContainer(examiner, entity, userSeeInsideSelf: true)) return true; // is it inside of an open storage (e.g., an open backpack)? return _interactionSystem.CanAccessViaStorage(examiner, entity); } [Pure] public bool CanExamine(EntityUid examiner, EntityUid examined) { // special check for client-side entities stored in null-space for some UI guff. if (IsClientSide(examined)) return true; return !Deleted(examined) && CanExamine(examiner, _transform.GetMapCoordinates(examined), entity => entity == examiner || entity == examined, examined); } [Pure] public virtual bool CanExamine(EntityUid examiner, MapCoordinates target, Ignored? predicate = null, EntityUid? examined = null, ExaminerComponent? examinerComp = null) { // TODO occluded container checks // also requires checking if the examiner has either a storage or stripping UI open, as the item may be accessible via that UI if (!Resolve(examiner, ref examinerComp, false)) return false; // Ghosts and admins skip examine checks. if (examinerComp.SkipChecks) return true; if (examined != null) { var ev = new ExamineAttemptEvent(examiner); RaiseLocalEvent(examined.Value, ev); if (ev.Cancelled) return false; } if (!examinerComp.CheckInRangeUnOccluded) return true; if (EntityManager.GetComponent(examiner).MapID != target.MapId) return false; return InRangeUnOccluded( _transform.GetMapCoordinates(examiner), target, GetExaminerRange(examiner), predicate: predicate, ignoreInsideBlocker: true); } /// /// Check if a given examiner is incapacitated. If yes, return a reduced examine range. Otherwise, return the deault range. /// public float GetExaminerRange(EntityUid examiner, MobStateComponent? mobState = null) { if (Resolve(examiner, ref mobState, logMissing: false)) { if (MobStateSystem.IsDead(examiner, mobState)) return DeadExamineRange; if (MobStateSystem.IsCritical(examiner, mobState) || TryComp(examiner, out var blind) && blind.IsBlind) return CritExamineRange; if (TryComp(examiner, out var blurry)) return Math.Clamp(ExamineRange - blurry.Magnitude * ExamineBlurrinessMult, 2, ExamineRange); } return ExamineRange; } /// /// True if occluders are drawn for this entity, otherwise false. /// public bool IsOccluded(EntityUid uid) { return TryComp(uid, out var eye) && eye.DrawFov; } public bool InRangeUnOccluded(MapCoordinates origin, MapCoordinates other, float range, Ignored? predicate, bool ignoreInsideBlocker = true, IEntityManager? entMan = null) { // No, rider. This is better. // ReSharper disable once ConvertToLocalFunction var wrapped = (EntityUid uid, Ignored? wrapped) => wrapped != null && wrapped(uid); return InRangeUnOccluded(origin, other, range, predicate, wrapped, ignoreInsideBlocker, entMan); } public bool InRangeUnOccluded(MapCoordinates origin, MapCoordinates other, float range, TState state, Func predicate, bool ignoreInsideBlocker = true, IEntityManager? entMan = null) { if (other.MapId != origin.MapId || other.MapId == MapId.Nullspace) return false; var dir = other.Position - origin.Position; var length = dir.Length(); // If range specified also check it // TODO: This rounding check is here because the API is kinda eh if (range > 0f && length > range + 0.01f) return false; if (MathHelper.CloseTo(length, 0)) return true; if (length > MaxRaycastRange) { Log.Warning("InRangeUnOccluded check performed over extreme range. Limiting CollisionRay size."); length = MaxRaycastRange; } var ray = new Ray(origin.Position, dir.Normalized()); var rayResults = _occluder .IntersectRayWithPredicate(origin.MapId, ray, length, state, predicate, false); if (rayResults.Count == 0) return true; if (!ignoreInsideBlocker) return false; foreach (var result in rayResults) { if (!TryComp(result.HitEntity, out OccluderComponent? o)) { continue; } var bBox = o.BoundingBox; bBox = bBox.Translated(_transform.GetWorldPosition(result.HitEntity)); if (bBox.Contains(origin.Position) || bBox.Contains(other.Position)) { continue; } return false; } return true; } public bool InRangeUnOccluded(EntityUid origin, EntityUid other, float range = ExamineRange, Ignored? predicate = null, bool ignoreInsideBlocker = true) { var originPos = _transform.GetMapCoordinates(origin); var otherPos = _transform.GetMapCoordinates(other); return InRangeUnOccluded(originPos, otherPos, range, predicate, ignoreInsideBlocker); } public bool InRangeUnOccluded(EntityUid origin, EntityCoordinates other, float range = ExamineRange, Ignored? predicate = null, bool ignoreInsideBlocker = true) { var originPos = _transform.GetMapCoordinates(origin); var otherPos = _transform.ToMapCoordinates(other); return InRangeUnOccluded(originPos, otherPos, range, predicate, ignoreInsideBlocker); } public bool InRangeUnOccluded(EntityUid origin, MapCoordinates other, float range = ExamineRange, Ignored? predicate = null, bool ignoreInsideBlocker = true) { var originPos = _transform.GetMapCoordinates(origin); return InRangeUnOccluded(originPos, other, range, predicate, ignoreInsideBlocker); } public FormattedMessage GetExamineText(EntityUid entity, EntityUid? examiner) { var message = new FormattedMessage(); if (examiner == null) { return message; } var hasDescription = false; var metadata = MetaData(entity); //Add an entity description if one is declared if (!string.IsNullOrEmpty(metadata.EntityDescription)) { message.AddText(metadata.EntityDescription); hasDescription = true; } message.PushColor(Color.DarkGray); // Raise the event and let things that subscribe to it change the message... var isInDetailsRange = IsInDetailsRange(examiner.Value, entity); var examinedEvent = new ExaminedEvent(message, entity, examiner.Value, isInDetailsRange, hasDescription); RaiseLocalEvent(entity, examinedEvent); var newMessage = examinedEvent.GetTotalMessage(); // pop color tag newMessage.Pop(); return newMessage; } } /// /// Raised when an entity is examined. /// If you're pushing multiple messages that should be grouped together (or ordered in some way), /// call before pushing and when finished. /// public sealed class ExaminedEvent : EntityEventArgs { /// /// The message that will be displayed as the examine text. /// You should use and similar instead to modify this, /// since it handles newlines/priority and such correctly. /// /// /// /// /// /// /// private FormattedMessage Message { get; } /// /// Parts of the examine message that will later be sorted by priority and pushed onto . /// private List Parts { get; } = new(); /// /// Whether the examiner is in range of the entity to get some extra details. /// public bool IsInDetailsRange { get; } /// /// The entity performing the examining. /// public EntityUid Examiner { get; } /// /// Entity being examined, for broadcast event purposes. /// public EntityUid Examined { get; } private bool _hasDescription; private ExamineMessagePart? _currentGroupPart; public ExaminedEvent(FormattedMessage message, EntityUid examined, EntityUid examiner, bool isInDetailsRange, bool hasDescription) { Message = message; Examined = examined; Examiner = examiner; IsInDetailsRange = isInDetailsRange; _hasDescription = hasDescription; } /// /// Returns with all appended according to their priority. /// public FormattedMessage GetTotalMessage() { int Comparison(ExamineMessagePart a, ExamineMessagePart b) { // Try sort by priority, then group, then by string contents if (a.Priority != b.Priority) { // negative so that expected behavior is consistent with what makes sense // i.e. a negative priority should mean its at the bottom of the list, right? return -a.Priority.CompareTo(b.Priority); } if (a.Group != b.Group) { return string.Compare(a.Group, b.Group, StringComparison.Ordinal); } return string.Compare(a.Message.ToString(), b.Message.ToString(), StringComparison.Ordinal); } // tolist/clone formatted message so calling this multiple times wont fuck shit up // (if that happens for some reason) var parts = Parts.ToList(); var totalMessage = new FormattedMessage(Message); parts.Sort(Comparison); if (_hasDescription) { totalMessage.PushNewline(); } foreach (var part in parts) { totalMessage.AddMessage(part.Message); if (part.DoNewLine && parts.Last() != part) totalMessage.PushNewline(); } return totalMessage; } /// /// Message group handling. Call this if you want the next set of examine messages that you're adding to have /// a consistent order with regards to each other. This is done so that client & server will always /// sort messages the same as well as grouped together properly, even if subscriptions are different. /// You should wrap it in a using() block so popping automatically occurs. /// public ExamineGroupDisposable PushGroup(string groupName, int priority=0) { // Ensure that other examine events correctly ended their groups. DebugTools.Assert(_currentGroupPart == null); _currentGroupPart = new ExamineMessagePart(new FormattedMessage(), priority, false, groupName); return new ExamineGroupDisposable(this); } /// /// Ends the current group and pushes its groups contents to the message. /// This will be called automatically if in using a `using` block with . /// private void PopGroup() { DebugTools.Assert(_currentGroupPart != null); if (_currentGroupPart != null) Parts.Add(_currentGroupPart); _currentGroupPart = null; } /// /// Push another message into this examine result, on its own line. /// End message will be grouped by , then by group if one was started /// then by ordinal comparison. /// /// /// public void PushMessage(FormattedMessage message, int priority=0) { if (message.Nodes.Count == 0) return; if (_currentGroupPart != null) { message.PushNewline(); _currentGroupPart.Message.AddMessage(message); } else { Parts.Add(new ExamineMessagePart(message, priority, true, null)); } } /// /// Push another message parsed from markup into this examine result, on its own line. /// End message will be grouped by , then by group if one was started /// then by ordinal comparison. /// /// /// public void PushMarkup(string markup, int priority=0) { PushMessage(FormattedMessage.FromMarkup(markup), priority); } /// /// Push another message containing raw text into this examine result, on its own line. /// End message will be grouped by , then by group if one was started /// then by ordinal comparison. /// /// /// public void PushText(string text, int priority=0) { var msg = new FormattedMessage(); msg.AddText(text); PushMessage(msg, priority); } /// /// Adds a message directly without starting a newline after. /// End message will be grouped by , then by group if one was started /// then by ordinal comparison. /// /// /// public void AddMessage(FormattedMessage message, int priority = 0) { if (message.Nodes.Count == 0) return; if (_currentGroupPart != null) { _currentGroupPart.Message.AddMessage(message); } else { Parts.Add(new ExamineMessagePart(message, priority, false, null)); } } /// /// Adds markup directly without starting a newline after. /// End message will be grouped by , then by group if one was started /// then by ordinal comparison. /// /// /// public void AddMarkup(string markup, int priority=0) { AddMessage(FormattedMessage.FromMarkup(markup), priority); } /// /// Adds text directly without starting a newline after. /// End message will be grouped by , then by group if one was started /// then by ordinal comparison. /// /// /// public void AddText(string text, int priority=0) { var msg = new FormattedMessage(); msg.AddText(text); AddMessage(msg, priority); } public struct ExamineGroupDisposable : IDisposable { private ExaminedEvent _event; public ExamineGroupDisposable(ExaminedEvent @event) { _event = @event; } public void Dispose() { _event.PopGroup(); } } private record ExamineMessagePart(FormattedMessage Message, int Priority, bool DoNewLine, string? Group); } /// /// Event raised directed at an entity that someone is attempting to examine /// public sealed class ExamineAttemptEvent : CancellableEntityEventArgs { public readonly EntityUid Examiner; public ExamineAttemptEvent(EntityUid examiner) { Examiner = examiner; } } }