diff --git a/code/modules/antagonists/_common/antag_datum.dm b/code/modules/antagonists/_common/antag_datum.dm
index 3184a169fc..c0f883707a 100644
--- a/code/modules/antagonists/_common/antag_datum.dm
+++ b/code/modules/antagonists/_common/antag_datum.dm
@@ -23,6 +23,7 @@ GLOBAL_LIST_EMPTY(antagonists)
var/show_name_in_check_antagonists = FALSE //Will append antagonist name in admin listings - use for categories that share more than one antag type
var/list/blacklisted_quirks = list(/datum/quirk/nonviolent,/datum/quirk/mute) // Quirks that will be removed upon gaining this antag. Pacifist and mute are default.
var/threat = 0 // Amount of threat this antag poses, for dynamic mode
+ var/show_to_ghosts = FALSE // Should this antagonist be shown as antag to ghosts? Shouldn't be used for stealthy antagonists like traitors
var/list/skill_modifiers
diff --git a/code/modules/antagonists/abductor/abductor.dm b/code/modules/antagonists/abductor/abductor.dm
index 9132288415..1a1db834c2 100644
--- a/code/modules/antagonists/abductor/abductor.dm
+++ b/code/modules/antagonists/abductor/abductor.dm
@@ -7,6 +7,7 @@
job_rank = ROLE_ABDUCTOR
show_in_antagpanel = FALSE //should only show subtypes
threat = 5
+ show_to_ghosts = TRUE
var/datum/team/abductor_team/team
var/sub_role
var/outfit
@@ -32,6 +33,10 @@
show_in_antagpanel = TRUE
skill_modifiers = list(/datum/skill_modifier/job/affinity/surgery)
+/datum/antagonist/abductor/scientist/onemanteam
+ name = "Abductor Solo"
+ outfit = /datum/outfit/abductor/scientist/onemanteam
+
/datum/antagonist/abductor/create_team(datum/team/abductor_team/new_team)
if(!new_team)
return
diff --git a/code/modules/antagonists/blob/blob.dm b/code/modules/antagonists/blob/blob.dm
index 1b076c9302..9a82bb546b 100644
--- a/code/modules/antagonists/blob/blob.dm
+++ b/code/modules/antagonists/blob/blob.dm
@@ -2,6 +2,7 @@
name = "Blob"
roundend_category = "blobs"
antagpanel_category = "Blob"
+ show_to_ghosts = TRUE
job_rank = ROLE_BLOB
threat = 20
var/datum/action/innate/blobpop/pop_action
diff --git a/code/modules/antagonists/devil/devil.dm b/code/modules/antagonists/devil/devil.dm
index c12259778e..3b6dc68986 100644
--- a/code/modules/antagonists/devil/devil.dm
+++ b/code/modules/antagonists/devil/devil.dm
@@ -92,6 +92,7 @@ GLOBAL_LIST_INIT(devil_suffix, list(" the Red", " the Soulless", " the Master",
//Don't delete upon mind destruction, otherwise soul re-selling will break.
delete_on_mind_deletion = FALSE
threat = 5
+ show_to_ghosts = TRUE
var/obligation
var/ban
var/bane
diff --git a/code/modules/antagonists/disease/disease_datum.dm b/code/modules/antagonists/disease/disease_datum.dm
index 7de0330ad6..b8e906064c 100644
--- a/code/modules/antagonists/disease/disease_datum.dm
+++ b/code/modules/antagonists/disease/disease_datum.dm
@@ -2,6 +2,7 @@
name = "Sentient Disease"
roundend_category = "diseases"
antagpanel_category = "Disease"
+ show_to_ghosts = TRUE
var/disease_name = ""
/datum/antagonist/disease/on_gain()
diff --git a/code/modules/antagonists/ert/ert.dm b/code/modules/antagonists/ert/ert.dm
index 1d773627c7..295616d052 100644
--- a/code/modules/antagonists/ert/ert.dm
+++ b/code/modules/antagonists/ert/ert.dm
@@ -12,6 +12,7 @@
var/list/name_source
threat = -5
show_in_antagpanel = FALSE
+ show_to_ghosts = TRUE
antag_moodlet = /datum/mood_event/focused
/datum/antagonist/ert/on_gain()
diff --git a/code/modules/antagonists/monkey/monkey.dm b/code/modules/antagonists/monkey/monkey.dm
index ebb39c814e..971532958f 100644
--- a/code/modules/antagonists/monkey/monkey.dm
+++ b/code/modules/antagonists/monkey/monkey.dm
@@ -9,6 +9,7 @@
roundend_category = "monkeys"
antagpanel_category = "Monkey"
threat = 3
+ show_to_ghosts = TRUE
var/datum/team/monkey/monkey_team
var/monkey_only = TRUE
diff --git a/code/modules/antagonists/nightmare/nightmare.dm b/code/modules/antagonists/nightmare/nightmare.dm
index 837b6e4216..f5b10de5c2 100644
--- a/code/modules/antagonists/nightmare/nightmare.dm
+++ b/code/modules/antagonists/nightmare/nightmare.dm
@@ -3,3 +3,4 @@
show_in_antagpanel = FALSE
show_name_in_check_antagonists = TRUE
threat = 5
+ show_to_ghosts = TRUE
diff --git a/code/modules/antagonists/ninja/ninja.dm b/code/modules/antagonists/ninja/ninja.dm
index 2615822dd8..414f7dd6b0 100644
--- a/code/modules/antagonists/ninja/ninja.dm
+++ b/code/modules/antagonists/ninja/ninja.dm
@@ -3,6 +3,7 @@
antagpanel_category = "Ninja"
job_rank = ROLE_NINJA
show_name_in_check_antagonists = TRUE
+ show_to_ghosts = TRUE
antag_moodlet = /datum/mood_event/focused
threat = 8
var/helping_station = FALSE
diff --git a/code/modules/antagonists/nukeop/nukeop.dm b/code/modules/antagonists/nukeop/nukeop.dm
index 454cde6d72..652b19a8e7 100644
--- a/code/modules/antagonists/nukeop/nukeop.dm
+++ b/code/modules/antagonists/nukeop/nukeop.dm
@@ -6,6 +6,7 @@
antag_moodlet = /datum/mood_event/focused
threat = 10
skill_modifiers = list(/datum/skill_modifier/job/level/wiring)
+ show_to_ghosts = TRUE
var/datum/team/nuclear/nuke_team
var/always_new_team = FALSE //If not assigned a team by default ops will try to join existing ones, set this to TRUE to always create new team.
var/send_to_spawnpoint = TRUE //Should the user be moved to default spawnpoint.
diff --git a/code/modules/antagonists/official/official.dm b/code/modules/antagonists/official/official.dm
index 1d340253c4..1ec64cb2b6 100644
--- a/code/modules/antagonists/official/official.dm
+++ b/code/modules/antagonists/official/official.dm
@@ -4,6 +4,7 @@
show_in_antagpanel = FALSE
var/datum/objective/mission
var/datum/team/ert/ert_team
+ show_to_ghosts = TRUE
/datum/antagonist/official/greet()
to_chat(owner, "You are a CentCom Official.")
diff --git a/code/modules/antagonists/pirate/pirate.dm b/code/modules/antagonists/pirate/pirate.dm
index 01f3c6068e..e6d350064d 100644
--- a/code/modules/antagonists/pirate/pirate.dm
+++ b/code/modules/antagonists/pirate/pirate.dm
@@ -4,6 +4,7 @@
roundend_category = "space pirates"
antagpanel_category = "Pirate"
threat = 5
+ show_to_ghosts = TRUE
var/datum/team/pirate/crew
/datum/antagonist/pirate/greet()
diff --git a/code/modules/antagonists/revenant/revenant_antag.dm b/code/modules/antagonists/revenant/revenant_antag.dm
index 46c1176533..c93291797a 100644
--- a/code/modules/antagonists/revenant/revenant_antag.dm
+++ b/code/modules/antagonists/revenant/revenant_antag.dm
@@ -3,6 +3,7 @@
show_in_antagpanel = FALSE
show_name_in_check_antagonists = TRUE
threat = 5
+ show_to_ghosts = TRUE
/datum/antagonist/revenant/greet()
owner.announce_objectives()
diff --git a/code/modules/antagonists/santa/santa.dm b/code/modules/antagonists/santa/santa.dm
index f58a21ba42..ff7dae98f6 100644
--- a/code/modules/antagonists/santa/santa.dm
+++ b/code/modules/antagonists/santa/santa.dm
@@ -1,6 +1,8 @@
/datum/antagonist/santa
name = "Santa"
show_in_antagpanel = FALSE
+ show_name_in_check_antagonists = TRUE
+ show_to_ghosts = TRUE
/datum/antagonist/santa/on_gain()
. = ..()
diff --git a/code/modules/antagonists/slaughter/slaughter_antag.dm b/code/modules/antagonists/slaughter/slaughter_antag.dm
index 04f7167fa5..87db9772b7 100644
--- a/code/modules/antagonists/slaughter/slaughter_antag.dm
+++ b/code/modules/antagonists/slaughter/slaughter_antag.dm
@@ -6,6 +6,7 @@
threat = 10
job_rank = ROLE_ALIEN
show_in_antagpanel = FALSE
+ show_to_ghosts = TRUE
/datum/antagonist/slaughter/on_gain()
forge_objectives()
diff --git a/code/modules/antagonists/wizard/wizard.dm b/code/modules/antagonists/wizard/wizard.dm
index 70adafd3fb..7263793f7f 100644
--- a/code/modules/antagonists/wizard/wizard.dm
+++ b/code/modules/antagonists/wizard/wizard.dm
@@ -13,6 +13,7 @@
var/move_to_lair = TRUE
var/outfit_type = /datum/outfit/wizard
var/wiz_age = WIZARD_AGE_MIN /* Wizards by nature cannot be too young. */
+ show_to_ghosts = TRUE
/datum/antagonist/wizard/on_gain()
register()
diff --git a/code/modules/antagonists/xeno/xeno.dm b/code/modules/antagonists/xeno/xeno.dm
index 7c4c5351df..2cc8e34b99 100644
--- a/code/modules/antagonists/xeno/xeno.dm
+++ b/code/modules/antagonists/xeno/xeno.dm
@@ -12,6 +12,7 @@
name = "Xenomorph"
job_rank = ROLE_ALIEN
show_in_antagpanel = FALSE
+ show_to_ghosts = TRUE
var/datum/team/xeno/xeno_team
threat = 3
diff --git a/code/modules/asset_cache/asset_list_items.dm b/code/modules/asset_cache/asset_list_items.dm
index 50eedcad93..d4289b9b31 100644
--- a/code/modules/asset_cache/asset_list_items.dm
+++ b/code/modules/asset_cache/asset_list_items.dm
@@ -369,9 +369,12 @@
"dna_extra.gif" = 'html/dna_extra.gif'
)
-/*
/datum/asset/simple/orbit
assets = list(
"ghost.png" = 'html/ghost.png'
)
+
+ assets = list(
+ "ghost.png" = 'html/ghost.png'
+ )
*/
diff --git a/code/modules/mob/dead/observer/observer.dm b/code/modules/mob/dead/observer/observer.dm
index 397af1b9d0..622a1a2221 100644
--- a/code/modules/mob/dead/observer/observer.dm
+++ b/code/modules/mob/dead/observer/observer.dm
@@ -54,6 +54,7 @@ GLOBAL_VAR_INIT(observer_default_invisibility, INVISIBILITY_OBSERVER)
// Used for displaying in ghost chat, without changing the actual name
// of the mob
var/deadchat_name
+ var/datum/orbit_menu/orbit_menu
var/datum/spawners_menu/spawners_menu
/mob/dead/observer/Initialize(mapload, mob/body)
@@ -161,6 +162,7 @@ GLOBAL_VAR_INIT(observer_default_invisibility, INVISIBILITY_OBSERVER)
updateallghostimages()
+ QDEL_NULL(orbit_menu)
QDEL_NULL(spawners_menu)
return ..()
@@ -490,10 +492,10 @@ This is the proc mobs get to turn into a ghost. Forked from ghostize due to comp
set name = "Orbit" // "Haunt"
set desc = "Follow and orbit a mob."
- var/list/mobs = getpois(skip_mindless=1)
- var/input = input("Please, select a mob!", "Haunt", null, null) as null|anything in mobs
- var/mob/target = mobs[input]
- ManualFollow(target)
+ if(!orbit_menu)
+ orbit_menu = new(src)
+
+ orbit_menu.ui_interact(src)
// This is the ghost's follow verb with an argument
/mob/dead/observer/proc/ManualFollow(atom/movable/target)
diff --git a/code/modules/mob/dead/observer/orbit.dm b/code/modules/mob/dead/observer/orbit.dm
new file mode 100644
index 0000000000..137de92db8
--- /dev/null
+++ b/code/modules/mob/dead/observer/orbit.dm
@@ -0,0 +1,78 @@
+/datum/orbit_menu
+ var/mob/dead/observer/owner
+
+/datum/orbit_menu/New(mob/dead/observer/new_owner)
+ if(!istype(new_owner))
+ qdel(src)
+ owner = new_owner
+
+/datum/orbit_menu/ui_interact(mob/user, ui_key = "main", datum/tgui/ui = null, force_open = FALSE, datum/tgui/master_ui = null, datum/ui_state/state = GLOB.observer_state)
+ if (!ui)
+ ui = new(user, src, ui_key, "Orbit", "Orbit", 350, 700, master_ui, state)
+ ui.open()
+
+/datum/orbit_menu/ui_act(action, list/params, datum/tgui/ui, datum/ui_state/state)
+ if (..())
+ return
+
+ if (action == "orbit")
+ var/list/pois = getpois(skip_mindless = 1)
+ var/atom/movable/poi = pois[params["name"]]
+ if (poi != null)
+ owner.ManualFollow(poi)
+ ui.close()
+
+/datum/orbit_menu/ui_data(mob/user)
+ var/list/data = list()
+
+ var/list/alive = list()
+ var/list/antagonists = list()
+ var/list/dead = list()
+ var/list/ghosts = list()
+ var/list/misc = list()
+ var/list/npcs = list()
+
+ var/list/pois = getpois(skip_mindless = 1)
+ for (var/name in pois)
+ var/list/serialized = list()
+ serialized["name"] = name
+
+ var/poi = pois[name]
+
+ var/mob/M = poi
+ if (istype(M))
+ if (isobserver(M))
+ ghosts += list(serialized)
+ else if (M.stat == DEAD)
+ dead += list(serialized)
+ else if (M.mind == null)
+ npcs += list(serialized)
+ else
+ var/number_of_orbiters = M.orbiters?.orbiters?.len
+ if (number_of_orbiters)
+ serialized["orbiters"] = number_of_orbiters
+
+ var/datum/mind/mind = M.mind
+ var/was_antagonist = FALSE
+
+ for (var/_A in mind.antag_datums)
+ var/datum/antagonist/A = _A
+ if (A.show_to_ghosts)
+ was_antagonist = TRUE
+ serialized["antag"] = A.name
+ antagonists += list(serialized)
+ break
+
+ if (!was_antagonist)
+ alive += list(serialized)
+ else
+ misc += list(serialized)
+
+ data["alive"] = alive
+ data["antagonists"] = antagonists
+ data["dead"] = dead
+ data["ghosts"] = ghosts
+ data["misc"] = misc
+ data["npcs"] = npcs
+
+ return data
diff --git a/html/ghost.png b/html/ghost.png
new file mode 100644
index 0000000000..e4b426ca47
Binary files /dev/null and b/html/ghost.png differ
diff --git a/tgstation.dme b/tgstation.dme
index a4f0fcf779..d80b9ccd09 100644
--- a/tgstation.dme
+++ b/tgstation.dme
@@ -2319,6 +2319,8 @@
#include "code\modules\mob\dead\observer\observer.dm"
#include "code\modules\mob\dead\observer\observer_movement.dm"
#include "code\modules\mob\dead\observer\say.dm"
+#include "code\modules\mob\dead\observer\observer_say.dm"
+#include "code\modules\mob\dead\observer\orbit.dm"
#include "code\modules\mob\living\blood.dm"
#include "code\modules\mob\living\bloodcrawl.dm"
#include "code\modules\mob\living\damage_procs.dm"
diff --git a/tgui/packages/tgui/interfaces/Orbit.js b/tgui/packages/tgui/interfaces/Orbit.js
new file mode 100644
index 0000000000..ec81293a80
--- /dev/null
+++ b/tgui/packages/tgui/interfaces/Orbit.js
@@ -0,0 +1,188 @@
+import { createSearch } from 'common/string';
+import { Box, Button, Input, Section } from '../components';
+import { Window } from '../layouts';
+import { useBackend, useLocalState } from '../backend';
+
+const PATTERN_DESCRIPTOR = / \[(?:ghost|dead)\]$/;
+const PATTERN_NUMBER = / \(([0-9]+)\)$/;
+
+const searchFor = searchText => createSearch(searchText, thing => thing.name);
+
+const compareString = (a, b) => a < b ? -1 : a > b;
+
+const compareNumberedText = (a, b) => {
+ const aName = a.name;
+ const bName = b.name;
+
+ // Check if aName and bName are the same except for a number at the end
+ // e.g. Medibot (2) and Medibot (3)
+ const aNumberMatch = aName.match(PATTERN_NUMBER);
+ const bNumberMatch = bName.match(PATTERN_NUMBER);
+
+ if (aNumberMatch
+ && bNumberMatch
+ && aName.replace(PATTERN_NUMBER, "") === bName.replace(PATTERN_NUMBER, "")
+ ) {
+ const aNumber = parseInt(aNumberMatch[1], 10);
+ const bNumber = parseInt(bNumberMatch[1], 10);
+
+ return aNumber - bNumber;
+ }
+
+ return compareString(aName, bName);
+};
+
+const BasicSection = (props, context) => {
+ const { act } = useBackend(context);
+ const { searchText, source, title } = props;
+ const things = source.filter(searchFor(searchText));
+ things.sort(compareNumberedText);
+ return source.length > 0 && (
+
+ {things.map(thing => (
+
+ );
+};
+
+const OrbitedButton = (props, context) => {
+ const { act } = useBackend(context);
+ const { color, thing } = props;
+
+ return (
+
+ );
+};
+
+export const Orbit = (props, context) => {
+ const { act, data } = useBackend(context);
+ const {
+ alive,
+ antagonists,
+ dead,
+ ghosts,
+ misc,
+ npcs,
+ } = data;
+
+ const [searchText, setSearchText] = useLocalState(context, "searchText", "");
+
+ const collatedAntagonists = {};
+ for (const antagonist of antagonists) {
+ if (collatedAntagonists[antagonist.antag] === undefined) {
+ collatedAntagonists[antagonist.antag] = [];
+ }
+ collatedAntagonists[antagonist.antag].push(antagonist);
+ }
+
+ const sortedAntagonists = Object.entries(collatedAntagonists);
+ sortedAntagonists.sort((a, b) => {
+ return compareString(a[0], b[0]);
+ });
+
+ const orbitMostRelevant = searchText => {
+ for (const source of [
+ sortedAntagonists.map(([_, antags]) => antags),
+ alive, ghosts, dead, npcs, misc,
+ ]) {
+ const member = source
+ .filter(searchFor(searchText))
+ .sort(compareNumberedText)[0];
+ if (member !== undefined) {
+ act("orbit", { name: member.name });
+ break;
+ }
+ }
+ };
+
+ return (
+
+
+
+
+ {antagonists.length > 0 && (
+
+ {sortedAntagonists.map(([name, antags]) => (
+
+ {antags
+ .filter(searchFor(searchText))
+ .sort(compareNumberedText)
+ .map(antag => (
+
+ ))}
+
+ ))}
+
+ )}
+
+
+ {alive
+ .filter(searchFor(searchText))
+ .sort(compareNumberedText)
+ .map(thing => (
+
+ ))}
+
+
+
+
+
+
+
+
+
+
+
+ );
+};