1
0

ExamineSystemShared.cs 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575
  1. using System.Diagnostics;
  2. using System.Globalization;
  3. using System.Linq;
  4. using System.Text;
  5. using Content.Shared.Eye.Blinding.Components;
  6. using Content.Shared.Ghost;
  7. using Content.Shared.Interaction;
  8. using Content.Shared.Mobs.Components;
  9. using Content.Shared.Mobs.Systems;
  10. using JetBrains.Annotations;
  11. using Robust.Shared.Containers;
  12. using Robust.Shared.Map;
  13. using Robust.Shared.Physics;
  14. using Robust.Shared.Utility;
  15. using static Content.Shared.Interaction.SharedInteractionSystem;
  16. namespace Content.Shared.Examine
  17. {
  18. public abstract partial class ExamineSystemShared : EntitySystem
  19. {
  20. [Dependency] private readonly OccluderSystem _occluder = default!;
  21. [Dependency] private readonly SharedTransformSystem _transform = default!;
  22. [Dependency] private readonly SharedContainerSystem _containerSystem = default!;
  23. [Dependency] private readonly SharedInteractionSystem _interactionSystem = default!;
  24. [Dependency] protected readonly MobStateSystem MobStateSystem = default!;
  25. public const float MaxRaycastRange = 100;
  26. /// <summary>
  27. /// Examine range to use when the examiner is in critical condition.
  28. /// </summary>
  29. /// <remarks>
  30. /// Detailed examinations are disabled while incapactiated. Ideally this should just be set equal to the
  31. /// radius of the crit overlay that blackens most of the screen. The actual radius of that is defined
  32. /// in a shader sooo... eh.
  33. /// </remarks>
  34. public const float CritExamineRange = 1.3f;
  35. /// <summary>
  36. /// Examine range to use when the examiner is dead. See <see cref="CritExamineRange"/>.
  37. /// </summary>
  38. public const float DeadExamineRange = 0.75f;
  39. public const float ExamineRange = 16f;
  40. protected const float ExamineDetailsRange = 3f;
  41. protected const float ExamineBlurrinessMult = 2.5f;
  42. private EntityQuery<GhostComponent> _ghostQuery;
  43. /// <summary>
  44. /// Creates a new examine tooltip with arbitrary info.
  45. /// </summary>
  46. public abstract void SendExamineTooltip(EntityUid player, EntityUid target, FormattedMessage message, bool getVerbs, bool centerAtCursor);
  47. public bool IsInDetailsRange(EntityUid examiner, EntityUid entity)
  48. {
  49. if (IsClientSide(entity))
  50. return true;
  51. // Ghosts can see everything.
  52. if (_ghostQuery.HasComp(examiner))
  53. return true;
  54. // check if the mob is in critical or dead
  55. if (MobStateSystem.IsIncapacitated(examiner))
  56. return false;
  57. if (!InRangeUnOccluded(examiner, entity, ExamineDetailsRange))
  58. return false;
  59. // Is the target hidden in a opaque locker or something? Currently this check allows players to examine
  60. // their organs, if they can somehow target them. Really this should be with userSeeInsideSelf: false, and a
  61. // separate check for if the item is in their inventory or hands.
  62. if (_containerSystem.IsInSameOrTransparentContainer(examiner, entity, userSeeInsideSelf: true))
  63. return true;
  64. // is it inside of an open storage (e.g., an open backpack)?
  65. return _interactionSystem.CanAccessViaStorage(examiner, entity);
  66. }
  67. [Pure]
  68. public bool CanExamine(EntityUid examiner, EntityUid examined)
  69. {
  70. // special check for client-side entities stored in null-space for some UI guff.
  71. if (IsClientSide(examined))
  72. return true;
  73. return !Deleted(examined) && CanExamine(examiner, _transform.GetMapCoordinates(examined),
  74. entity => entity == examiner || entity == examined, examined);
  75. }
  76. [Pure]
  77. public virtual bool CanExamine(EntityUid examiner, MapCoordinates target, Ignored? predicate = null, EntityUid? examined = null, ExaminerComponent? examinerComp = null)
  78. {
  79. // TODO occluded container checks
  80. // also requires checking if the examiner has either a storage or stripping UI open, as the item may be accessible via that UI
  81. if (!Resolve(examiner, ref examinerComp, false))
  82. return false;
  83. // Ghosts and admins skip examine checks.
  84. if (examinerComp.SkipChecks)
  85. return true;
  86. if (examined != null)
  87. {
  88. var ev = new ExamineAttemptEvent(examiner);
  89. RaiseLocalEvent(examined.Value, ev);
  90. if (ev.Cancelled)
  91. return false;
  92. }
  93. if (!examinerComp.CheckInRangeUnOccluded)
  94. return true;
  95. if (EntityManager.GetComponent<TransformComponent>(examiner).MapID != target.MapId)
  96. return false;
  97. // Do target InRangeUnoccluded which has different checks.
  98. if (examined != null)
  99. {
  100. return InRangeUnOccluded(
  101. examiner,
  102. examined.Value,
  103. GetExaminerRange(examiner),
  104. predicate: predicate,
  105. ignoreInsideBlocker: true);
  106. }
  107. else
  108. {
  109. return InRangeUnOccluded(
  110. examiner,
  111. target,
  112. GetExaminerRange(examiner),
  113. predicate: predicate,
  114. ignoreInsideBlocker: true);
  115. }
  116. }
  117. /// <summary>
  118. /// Check if a given examiner is incapacitated. If yes, return a reduced examine range. Otherwise, return the deault range.
  119. /// </summary>
  120. public float GetExaminerRange(EntityUid examiner, MobStateComponent? mobState = null)
  121. {
  122. if (Resolve(examiner, ref mobState, logMissing: false))
  123. {
  124. if (MobStateSystem.IsDead(examiner, mobState))
  125. return DeadExamineRange;
  126. if (MobStateSystem.IsCritical(examiner, mobState) || TryComp<BlindableComponent>(examiner, out var blind) && blind.IsBlind)
  127. return CritExamineRange;
  128. if (TryComp<BlurryVisionComponent>(examiner, out var blurry))
  129. return Math.Clamp(ExamineRange - blurry.Magnitude * ExamineBlurrinessMult, 2, ExamineRange);
  130. }
  131. return ExamineRange;
  132. }
  133. /// <summary>
  134. /// True if occluders are drawn for this entity, otherwise false.
  135. /// </summary>
  136. public bool IsOccluded(EntityUid uid)
  137. {
  138. return TryComp<EyeComponent>(uid, out var eye) && eye.DrawFov;
  139. }
  140. public bool InRangeUnOccluded(MapCoordinates origin, MapCoordinates other, float range, Ignored? predicate, bool ignoreInsideBlocker = true, IEntityManager? entMan = null)
  141. {
  142. // No, rider. This is better.
  143. // ReSharper disable once ConvertToLocalFunction
  144. var wrapped = (EntityUid uid, Ignored? wrapped)
  145. => wrapped != null && wrapped(uid);
  146. return InRangeUnOccluded(origin, other, range, predicate, wrapped, ignoreInsideBlocker, entMan);
  147. }
  148. public bool InRangeUnOccluded<TState>(MapCoordinates origin, MapCoordinates other, float range,
  149. TState state, Func<EntityUid, TState, bool> predicate, bool ignoreInsideBlocker = true, IEntityManager? entMan = null)
  150. {
  151. if (other.MapId != origin.MapId ||
  152. other.MapId == MapId.Nullspace) return false;
  153. var dir = other.Position - origin.Position;
  154. var length = dir.Length();
  155. // If range specified also check it
  156. // TODO: This rounding check is here because the API is kinda eh
  157. if (range > 0f && length > range + 0.01f) return false;
  158. if (MathHelper.CloseTo(length, 0)) return true;
  159. if (length > MaxRaycastRange)
  160. {
  161. Log.Warning("InRangeUnOccluded check performed over extreme range. Limiting CollisionRay size.");
  162. length = MaxRaycastRange;
  163. }
  164. var ray = new Ray(origin.Position, dir.Normalized());
  165. var rayResults = _occluder
  166. .IntersectRayWithPredicate(origin.MapId, ray, length, state, predicate, false);
  167. if (rayResults.Count == 0) return true;
  168. if (!ignoreInsideBlocker) return false;
  169. foreach (var result in rayResults)
  170. {
  171. if (!TryComp(result.HitEntity, out OccluderComponent? o))
  172. {
  173. continue;
  174. }
  175. var bBox = o.BoundingBox;
  176. bBox = bBox.Translated(_transform.GetWorldPosition(result.HitEntity));
  177. if (bBox.Contains(origin.Position) || bBox.Contains(other.Position))
  178. {
  179. continue;
  180. }
  181. return false;
  182. }
  183. return true;
  184. }
  185. public bool InRangeUnOccluded(EntityUid origin, EntityUid other, float range = ExamineRange, Ignored? predicate = null, bool ignoreInsideBlocker = true)
  186. {
  187. var ev = new InRangeOverrideEvent(origin, other);
  188. RaiseLocalEvent(origin, ref ev);
  189. if (ev.Handled)
  190. {
  191. return ev.InRange;
  192. }
  193. var originPos = _transform.GetMapCoordinates(origin);
  194. var otherPos = _transform.GetMapCoordinates(other);
  195. return InRangeUnOccluded(originPos, otherPos, range, predicate, ignoreInsideBlocker);
  196. }
  197. public bool InRangeUnOccluded(EntityUid origin, EntityCoordinates other, float range = ExamineRange, Ignored? predicate = null, bool ignoreInsideBlocker = true)
  198. {
  199. var originPos = _transform.GetMapCoordinates(origin);
  200. var otherPos = _transform.ToMapCoordinates(other);
  201. return InRangeUnOccluded(originPos, otherPos, range, predicate, ignoreInsideBlocker);
  202. }
  203. public bool InRangeUnOccluded(EntityUid origin, MapCoordinates other, float range = ExamineRange, Ignored? predicate = null, bool ignoreInsideBlocker = true)
  204. {
  205. var originPos = _transform.GetMapCoordinates(origin);
  206. return InRangeUnOccluded(originPos, other, range, predicate, ignoreInsideBlocker);
  207. }
  208. public FormattedMessage GetExamineText(EntityUid entity, EntityUid? examiner)
  209. {
  210. var message = new FormattedMessage();
  211. if (examiner == null)
  212. {
  213. return message;
  214. }
  215. var hasDescription = false;
  216. var metadata = MetaData(entity);
  217. //Add an entity description if one is declared
  218. if (!string.IsNullOrEmpty(metadata.EntityDescription))
  219. {
  220. message.AddText(metadata.EntityDescription);
  221. hasDescription = true;
  222. }
  223. message.PushColor(Color.DarkGray);
  224. // Raise the event and let things that subscribe to it change the message...
  225. var isInDetailsRange = IsInDetailsRange(examiner.Value, entity);
  226. var examinedEvent = new ExaminedEvent(message, entity, examiner.Value, isInDetailsRange, hasDescription);
  227. RaiseLocalEvent(entity, examinedEvent);
  228. var newMessage = examinedEvent.GetTotalMessage();
  229. // pop color tag
  230. newMessage.Pop();
  231. return newMessage;
  232. }
  233. }
  234. /// <summary>
  235. /// Raised when an entity is examined.
  236. /// If you're pushing multiple messages that should be grouped together (or ordered in some way),
  237. /// call <see cref="PushGroup"/> before pushing and <see cref="PopGroup"/> when finished.
  238. /// </summary>
  239. public sealed class ExaminedEvent : EntityEventArgs
  240. {
  241. /// <summary>
  242. /// The message that will be displayed as the examine text.
  243. /// You should use <see cref="PushMarkup"/> and similar instead to modify this,
  244. /// since it handles newlines/priority and such correctly.
  245. /// </summary>
  246. /// <seealso cref="PushMessage"/>
  247. /// <seealso cref="PushMarkup"/>
  248. /// <seealso cref="PushText"/>
  249. /// <seealso cref="AddMessage"/>
  250. /// <seealso cref="AddMarkup"/>
  251. /// <seealso cref="AddText"/>
  252. private FormattedMessage Message { get; }
  253. /// <summary>
  254. /// Parts of the examine message that will later be sorted by priority and pushed onto <see cref="Message"/>.
  255. /// </summary>
  256. private List<ExamineMessagePart> Parts { get; } = new();
  257. /// <summary>
  258. /// Whether the examiner is in range of the entity to get some extra details.
  259. /// </summary>
  260. public bool IsInDetailsRange { get; }
  261. /// <summary>
  262. /// The entity performing the examining.
  263. /// </summary>
  264. public EntityUid Examiner { get; }
  265. /// <summary>
  266. /// Entity being examined, for broadcast event purposes.
  267. /// </summary>
  268. public EntityUid Examined { get; }
  269. private bool _hasDescription;
  270. private ExamineMessagePart? _currentGroupPart;
  271. public ExaminedEvent(FormattedMessage message, EntityUid examined, EntityUid examiner, bool isInDetailsRange, bool hasDescription)
  272. {
  273. Message = message;
  274. Examined = examined;
  275. Examiner = examiner;
  276. IsInDetailsRange = isInDetailsRange;
  277. _hasDescription = hasDescription;
  278. }
  279. /// <summary>
  280. /// Returns <see cref="Message"/> with all <see cref="Parts"/> appended according to their priority.
  281. /// </summary>
  282. public FormattedMessage GetTotalMessage()
  283. {
  284. int Comparison(ExamineMessagePart a, ExamineMessagePart b)
  285. {
  286. // Try sort by priority, then group, then by string contents
  287. if (a.Priority != b.Priority)
  288. {
  289. // negative so that expected behavior is consistent with what makes sense
  290. // i.e. a negative priority should mean its at the bottom of the list, right?
  291. return -a.Priority.CompareTo(b.Priority);
  292. }
  293. if (a.Group != b.Group)
  294. {
  295. return string.Compare(a.Group, b.Group, StringComparison.Ordinal);
  296. }
  297. return string.Compare(a.Message.ToString(), b.Message.ToString(), StringComparison.Ordinal);
  298. }
  299. // tolist/clone formatted message so calling this multiple times wont fuck shit up
  300. // (if that happens for some reason)
  301. var parts = Parts.ToList();
  302. var totalMessage = new FormattedMessage(Message);
  303. parts.Sort(Comparison);
  304. if (_hasDescription && parts.Count > 0)
  305. {
  306. totalMessage.PushNewline();
  307. }
  308. foreach (var part in parts)
  309. {
  310. totalMessage.AddMessage(part.Message);
  311. if (part.DoNewLine && parts.Last() != part)
  312. totalMessage.PushNewline();
  313. }
  314. totalMessage.TrimEnd();
  315. return totalMessage;
  316. }
  317. /// <summary>
  318. /// Message group handling. Call this if you want the next set of examine messages that you're adding to have
  319. /// a consistent order with regards to each other. This is done so that client & server will always
  320. /// sort messages the same as well as grouped together properly, even if subscriptions are different.
  321. /// You should wrap it in a using() block so popping automatically occurs.
  322. /// <summary>
  323. /// Begins a new message group for examine text, allowing related messages to be grouped and ordered together.
  324. /// </summary>
  325. /// <param name="groupName">The name of the group for identification and ordering.</param>
  326. /// <param name="priority">The priority of the group, affecting its order in the final message.</param>
  327. /// <returns>A disposable object that ends the group when disposed.</returns>
  328. public ExamineGroupDisposable PushGroup(string groupName, int priority = 0)
  329. {
  330. // Ensure that other examine events correctly ended their groups.
  331. DebugTools.Assert(_currentGroupPart == null);
  332. _currentGroupPart = new ExamineMessagePart(new FormattedMessage(), priority, false, groupName);
  333. return new ExamineGroupDisposable(this);
  334. }
  335. /// <summary>
  336. /// Ends the current group and pushes its groups contents to the message.
  337. /// This will be called automatically if in using a `using` block with <see cref="PushGroup"/>.
  338. /// </summary>
  339. private void PopGroup()
  340. {
  341. DebugTools.Assert(_currentGroupPart != null);
  342. if (_currentGroupPart != null && !_currentGroupPart.Message.IsEmpty)
  343. {
  344. Parts.Add(_currentGroupPart);
  345. }
  346. _currentGroupPart = null;
  347. }
  348. /// <summary>
  349. /// Push another message into this examine result, on its own line.
  350. /// End message will be grouped by <see cref="priority"/>, then by group if one was started
  351. /// then by ordinal comparison.
  352. /// </summary>
  353. /// <seealso cref="PushMarkup"/>
  354. /// <summary>
  355. /// Adds a formatted message as a new line to the examine message, either within the current group or as a separate part.
  356. /// </summary>
  357. /// <param name="message">The formatted message to add.</param>
  358. /// <param name="priority">The priority for ordering this message part.</param>
  359. public void PushMessage(FormattedMessage message, int priority = 0)
  360. {
  361. if (message.Nodes.Count == 0)
  362. return;
  363. if (_currentGroupPart != null)
  364. {
  365. message.PushNewline();
  366. _currentGroupPart.Message.AddMessage(message);
  367. }
  368. else
  369. {
  370. Parts.Add(new ExamineMessagePart(message, priority, true, null));
  371. }
  372. }
  373. /// <summary>
  374. /// Push another message parsed from markup into this examine result, on its own line.
  375. /// End message will be grouped by <see cref="priority"/>, then by group if one was started
  376. /// then by ordinal comparison.
  377. /// </summary>
  378. /// <seealso cref="PushText"/>
  379. /// <summary>
  380. /// Parses markup text and adds it as a new message part on its own line, with optional priority for ordering.
  381. /// </summary>
  382. /// <param name="markup">The markup-formatted string to add.</param>
  383. /// <param name="priority">Optional priority for message ordering; higher values appear later.</param>
  384. public void PushMarkup(string markup, int priority = 0)
  385. {
  386. PushMessage(FormattedMessage.FromMarkupOrThrow(markup), priority);
  387. }
  388. /// <summary>
  389. /// Push another message containing raw text into this examine result, on its own line.
  390. /// End message will be grouped by <see cref="priority"/>, then by group if one was started
  391. /// then by ordinal comparison.
  392. /// </summary>
  393. /// <seealso cref="PushMarkup"/>
  394. /// <summary>
  395. /// Adds a line of plain text to the examine message, optionally specifying its priority.
  396. /// </summary>
  397. /// <param name="text">The text to add as a separate message line.</param>
  398. /// <param name="priority">The priority for ordering this message part. Higher values appear later.</param>
  399. public void PushText(string text, int priority = 0)
  400. {
  401. var msg = new FormattedMessage();
  402. msg.AddText(text);
  403. PushMessage(msg, priority);
  404. }
  405. /// <summary>
  406. /// Adds a message directly without starting a newline after.
  407. /// End message will be grouped by <see cref="priority"/>, then by group if one was started
  408. /// then by ordinal comparison.
  409. /// </summary>
  410. /// <seealso cref="AddMarkup"/>
  411. /// <seealso cref="AddText"/>
  412. public void AddMessage(FormattedMessage message, int priority = 0)
  413. {
  414. if (message.Nodes.Count == 0)
  415. return;
  416. if (_currentGroupPart != null)
  417. {
  418. _currentGroupPart.Message.AddMessage(message);
  419. }
  420. else
  421. {
  422. Parts.Add(new ExamineMessagePart(message, priority, false, null));
  423. }
  424. }
  425. /// <summary>
  426. /// Adds markup directly without starting a newline after.
  427. /// End message will be grouped by <see cref="priority"/>, then by group if one was started
  428. /// then by ordinal comparison.
  429. /// </summary>
  430. /// <seealso cref="AddText"/>
  431. /// <summary>
  432. /// Adds markup-formatted text inline to the examine message without a newline.
  433. /// </summary>
  434. /// <param name="markup">The markup-formatted string to add.</param>
  435. /// <param name="priority">The priority for ordering this message part.</param>
  436. public void AddMarkup(string markup, int priority = 0)
  437. {
  438. AddMessage(FormattedMessage.FromMarkupOrThrow(markup), priority);
  439. }
  440. /// <summary>
  441. /// Adds text directly without starting a newline after.
  442. /// End message will be grouped by <see cref="priority"/>, then by group if one was started
  443. /// then by ordinal comparison.
  444. /// </summary>
  445. /// <seealso cref="AddMarkup"/>
  446. /// <summary>
  447. /// Adds plain text inline to the examine message at the specified priority.
  448. /// </summary>
  449. /// <param name="text">The text to add to the message.</param>
  450. /// <param name="priority">The priority for ordering this message part. Higher values appear later.</param>
  451. public void AddText(string text, int priority = 0)
  452. {
  453. var msg = new FormattedMessage();
  454. msg.AddText(text);
  455. AddMessage(msg, priority);
  456. }
  457. public struct ExamineGroupDisposable : IDisposable
  458. {
  459. private ExaminedEvent _event;
  460. public ExamineGroupDisposable(ExaminedEvent @event)
  461. {
  462. _event = @event;
  463. }
  464. public void Dispose()
  465. {
  466. _event.PopGroup();
  467. }
  468. }
  469. private record ExamineMessagePart(FormattedMessage Message, int Priority, bool DoNewLine, string? Group);
  470. }
  471. /// <summary>
  472. /// Event raised directed at an entity that someone is attempting to examine
  473. /// </summary>
  474. public sealed class ExamineAttemptEvent : CancellableEntityEventArgs
  475. {
  476. public readonly EntityUid Examiner;
  477. public ExamineAttemptEvent(EntityUid examiner)
  478. {
  479. Examiner = examiner;
  480. }
  481. }
  482. }