diff --git a/code/datums/components/orbiter.dm b/code/datums/components/orbiter.dm
index 3ba6ba22c07..71f391e599a 100644
--- a/code/datums/components/orbiter.dm
+++ b/code/datums/components/orbiter.dm
@@ -120,6 +120,10 @@
orbiter_mob.updating_glide_size = TRUE
orbiter_mob.glide_size = 8
+ if(isobserver(orbiter))
+ var/mob/dead/observer/ghostie = orbiter
+ ghostie.orbiting_ref = null
+
REMOVE_TRAIT(orbiter, TRAIT_NO_FLOATING_ANIM, ORBITING_TRAIT)
if(!refreshing && !length(orbiter_list) && !QDELING(src))
diff --git a/code/modules/antagonists/space_ninja/space_ninja.dm b/code/modules/antagonists/space_ninja/space_ninja.dm
index a2bec8ae761..f0187a6d61e 100644
--- a/code/modules/antagonists/space_ninja/space_ninja.dm
+++ b/code/modules/antagonists/space_ninja/space_ninja.dm
@@ -2,7 +2,7 @@
name = "\improper Space Ninja"
antagpanel_category = ANTAG_GROUP_NINJAS
job_rank = ROLE_NINJA
- antag_hud_name = "space_ninja"
+ antag_hud_name = "ninja"
hijack_speed = 1
show_name_in_check_antagonists = TRUE
show_to_ghosts = TRUE
diff --git a/code/modules/mob/dead/observer/observer.dm b/code/modules/mob/dead/observer/observer.dm
index 6be32986b67..31b739642be 100644
--- a/code/modules/mob/dead/observer/observer.dm
+++ b/code/modules/mob/dead/observer/observer.dm
@@ -60,6 +60,9 @@ GLOBAL_VAR_INIT(observer_default_invisibility, INVISIBILITY_OBSERVER)
var/datum/spawners_menu/spawners_menu
var/datum/minigames_menu/minigames_menu
+ /// The POI we're orbiting (orbit menu)
+ var/orbiting_ref
+
/mob/dead/observer/Initialize(mapload)
set_invisibility(GLOB.observer_default_invisibility)
@@ -1091,3 +1094,10 @@ This is the proc mobs get to turn into a ghost. Forked from ghostize due to comp
if(!prefs || (client?.combo_hud_enabled && prefs.toggles & COMBOHUD_LIGHTING))
return ..()
return GLOB.ghost_lighting_options[prefs.read_preference(/datum/preference/choiced/ghost_lighting)]
+
+
+/// Called when we exit the orbiting state
+/mob/dead/observer/proc/on_deorbit(datum/source)
+ SIGNAL_HANDLER
+
+ orbiting_ref = null
diff --git a/code/modules/mob/dead/observer/orbit.dm b/code/modules/mob/dead/observer/orbit.dm
index db6acc346b1..ee0b4528995 100644
--- a/code/modules/mob/dead/observer/orbit.dm
+++ b/code/modules/mob/dead/observer/orbit.dm
@@ -36,19 +36,33 @@ GLOBAL_DATUM_INIT(orbit_menu, /datum/orbit_menu, new)
var/mob/dead/observer/user = usr
user.ManualFollow(poi)
user.reset_perspective(null)
+ user.orbiting_ref = ref
if (auto_observe)
user.do_observe(poi)
return TRUE
if ("refresh")
- update_static_data(usr, ui)
+ ui.send_full_update()
return TRUE
+ return FALSE
+
+
+/datum/orbit_menu/ui_data(mob/user)
+ var/list/data = list()
+
+ if(isobserver(user))
+ data["orbiting"] = get_currently_orbiting(user)
+
+ return data
+
+
/datum/orbit_menu/ui_static_data(mob/user)
var/list/new_mob_pois = SSpoints_of_interest.get_mob_pois(CALLBACK(src, PROC_REF(validate_mob_poi)), append_dead_role = FALSE)
var/list/new_other_pois = SSpoints_of_interest.get_other_pois()
var/list/alive = list()
var/list/antagonists = list()
+ var/list/critical = list()
var/list/deadchat_controlled = list()
var/list/dead = list()
var/list/ghosts = list()
@@ -57,14 +71,10 @@ GLOBAL_DATUM_INIT(orbit_menu, /datum/orbit_menu, new)
for(var/name in new_mob_pois)
var/list/serialized = list()
-
var/mob/mob_poi = new_mob_pois[name]
-
- var/poi_ref = REF(mob_poi)
-
var/number_of_orbiters = length(mob_poi.get_all_orbiters())
- serialized["ref"] = poi_ref
+ serialized["ref"] = REF(mob_poi)
serialized["full_name"] = name
if(number_of_orbiters)
serialized["orbiters"] = number_of_orbiters
@@ -81,33 +91,26 @@ GLOBAL_DATUM_INIT(orbit_menu, /datum/orbit_menu, new)
continue
if(isnull(mob_poi.mind))
+ if(isliving(mob_poi))
+ var/mob/living/npc = mob_poi
+ serialized["health"] = FLOOR((npc.health / npc.maxHealth * 100), 1)
+
npcs += list(serialized)
continue
- var/datum/mind/mind = mob_poi.mind
- var/was_antagonist = FALSE
-
+ serialized["client"] = !!mob_poi.client
serialized["name"] = mob_poi.real_name
- if(isliving(mob_poi)) // handles edge cases like blob
- var/mob/living/player = mob_poi
- serialized["health"] = FLOOR((player.health / player.maxHealth * 100), 1)
- if(issilicon(player))
- serialized["job"] = player.job
- else
- var/obj/item/card/id/id_card = player.get_idcard(hand_first = FALSE)
- serialized["job"] = id_card?.get_trim_assignment()
-
- for(var/datum/antagonist/antag_datum as anything in mind.antag_datums)
- if (antag_datum.show_to_ghosts)
- was_antagonist = TRUE
- serialized["antag"] = antag_datum.name
- serialized["antag_group"] = antag_datum.antagpanel_category
- antagonists += list(serialized)
- break
-
- if(!was_antagonist)
- alive += list(serialized)
+ if(isliving(mob_poi))
+ serialized += get_living_data(mob_poi)
+
+ var/list/antag_data = get_antag_data(mob_poi.mind)
+ if(length(antag_data))
+ serialized += antag_data
+ antagonists += list(serialized)
+ continue
+
+ alive += list(serialized)
for(var/name in new_other_pois)
var/atom/atom_poi = new_other_pois[name]
@@ -122,32 +125,18 @@ GLOBAL_DATUM_INIT(orbit_menu, /datum/orbit_menu, new)
))
continue
- misc += list(list(
- "ref" = REF(atom_poi),
- "full_name" = name,
- ))
+ var/list/other_data = get_misc_data(atom_poi)
+ var/misc_data = list(other_data[1])
- // Display the supermatter crystal integrity
- if(istype(atom_poi, /obj/machinery/power/supermatter_crystal))
- var/obj/machinery/power/supermatter_crystal/crystal = atom_poi
- misc[length(misc)]["extra"] = "Integrity: [round(crystal.get_integrity_percent())]%"
- continue
- // Display the nuke timer
- if(istype(atom_poi, /obj/machinery/nuclearbomb))
- var/obj/machinery/nuclearbomb/bomb = atom_poi
- if(bomb.timing)
- misc[length(misc)]["extra"] = "Timer: [bomb.countdown?.displayed_text]s"
- continue
- // Display the holder if its a nuke disk
- if(istype(atom_poi, /obj/item/disk/nuclear))
- var/obj/item/disk/nuclear/disk = atom_poi
- var/mob/holder = disk.pulledby || get(disk, /mob)
- misc[length(misc)]["extra"] = "Location: [holder?.real_name || "Unsecured"]"
- continue
+ misc += misc_data
+
+ if(other_data[2]) // Critical = TRUE
+ critical += misc_data
return list(
"alive" = alive,
"antagonists" = antagonists,
+ "critical" = critical,
"deadchat_controlled" = deadchat_controlled,
"dead" = dead,
"ghosts" = ghosts,
@@ -155,10 +144,131 @@ GLOBAL_DATUM_INIT(orbit_menu, /datum/orbit_menu, new)
"npcs" = npcs,
)
+
/// Shows the UI to the specified user.
/datum/orbit_menu/proc/show(mob/user)
ui_interact(user)
+
+/// Helper function to get threat type, group, overrides for job and icon
+/datum/orbit_menu/proc/get_antag_data(datum/mind/poi_mind) as /list
+ var/list/serialized = list()
+
+ for(var/datum/antagonist/antag as anything in poi_mind.antag_datums)
+ if(!antag.show_to_ghosts)
+ continue
+
+ serialized["antag"] = antag.name
+ serialized["antag_group"] = antag.antagpanel_category
+ serialized["job"] = antag.name
+ serialized["icon"] = antag.antag_hud_name
+
+ return serialized
+
+
+/// Helper to get the current thing we're orbiting (if any)
+/datum/orbit_menu/proc/get_currently_orbiting(mob/dead/observer/user)
+ if(isnull(user.orbiting_ref))
+ return
+
+ var/atom/poi = SSpoints_of_interest.get_poi_atom_by_ref(user.orbiting_ref)
+ if(isnull(poi))
+ user.orbiting_ref = null
+ return
+
+ if((ismob(poi) && !SSpoints_of_interest.is_valid_poi(poi, CALLBACK(src, PROC_REF(validate_mob_poi)))) \
+ || !SSpoints_of_interest.is_valid_poi(poi)
+ )
+ user.orbiting_ref = null
+ return
+
+ var/list/serialized = list()
+
+ if(!ismob(poi))
+ var/list/misc_info = get_misc_data(poi)
+ serialized += misc_info[1]
+ return serialized
+
+ var/mob/mob_poi = poi
+ serialized["full_name"] = mob_poi.name
+ serialized["ref"] = REF(poi)
+
+ if(mob_poi.mind)
+ serialized["client"] = !!mob_poi.client
+ serialized["name"] = mob_poi.real_name
+
+ if(isliving(mob_poi))
+ serialized += get_living_data(mob_poi)
+
+ return serialized
+
+
+/// Helper function to get job / icon / health data for a living mob
+/datum/orbit_menu/proc/get_living_data(mob/living/player) as /list
+ var/list/serialized = list()
+
+ serialized["health"] = FLOOR((player.health / player.maxHealth * 100), 1)
+ if(issilicon(player))
+ serialized["job"] = player.job
+ serialized["icon"] = "borg"
+ else
+ var/obj/item/card/id/id_card = player.get_idcard(hand_first = FALSE)
+ serialized["job"] = id_card?.get_trim_assignment()
+ serialized["icon"] = id_card?.get_trim_sechud_icon_state()
+
+ return serialized
+
+
+/// Gets a list: Misc data and whether it's critical. Handles all snowflakey type cases
+/datum/orbit_menu/proc/get_misc_data(atom/movable/atom_poi) as /list
+ var/list/misc = list()
+ var/critical = FALSE
+
+ misc["ref"] = REF(atom_poi)
+ misc["full_name"] = atom_poi.name
+
+ // Display the supermatter crystal integrity
+ if(istype(atom_poi, /obj/machinery/power/supermatter_crystal))
+ var/obj/machinery/power/supermatter_crystal/crystal = atom_poi
+ var/integrity = round(crystal.get_integrity_percent())
+ misc["extra"] = "Integrity: [integrity]%"
+
+ if(integrity < 10)
+ critical = TRUE
+
+ return list(misc, critical)
+
+ // Display the nuke timer
+ if(istype(atom_poi, /obj/machinery/nuclearbomb))
+ var/obj/machinery/nuclearbomb/bomb = atom_poi
+
+ if(bomb.timing)
+ misc["extra"] = "Timer: [bomb.countdown?.displayed_text]s"
+ critical = TRUE
+
+ return list(misc, critical)
+
+ // Display the holder if its a nuke disk
+ if(istype(atom_poi, /obj/item/disk/nuclear))
+ var/obj/item/disk/nuclear/disk = atom_poi
+ var/mob/holder = disk.pulledby || get(disk, /mob)
+ misc["extra"] = "Location: [holder?.real_name || "Unsecured"]"
+
+ return list(misc, critical)
+
+ // Display singuloths if they exist
+ if(istype(atom_poi, /obj/singularity))
+ var/obj/singularity/singulo = atom_poi
+ misc["extra"] = "Energy: [round(singulo.energy)]"
+
+ if(singulo.current_size > 2)
+ critical = TRUE
+
+ return list(misc, critical)
+
+ return list(misc, critical)
+
+
/**
* Helper POI validation function passed as a callback to various SSpoints_of_interest procs.
*
@@ -181,3 +291,4 @@ GLOBAL_DATUM_INIT(orbit_menu, /datum/orbit_menu, new)
return FALSE
return potential_poi.validate()
+
diff --git a/tgui/packages/tgui/interfaces/Orbit/JobIcon.tsx b/tgui/packages/tgui/interfaces/Orbit/JobIcon.tsx
new file mode 100644
index 00000000000..70c3ae0f5dc
--- /dev/null
+++ b/tgui/packages/tgui/interfaces/Orbit/JobIcon.tsx
@@ -0,0 +1,57 @@
+import { DmIcon, Icon } from '../../components';
+import { JOB2ICON } from '../common/JobToIcon';
+import { Antagonist, Observable } from './types';
+
+type Props = {
+ item: Observable | Antagonist;
+};
+
+type IconSettings = {
+ dmi: string;
+ transform: string;
+};
+
+const normalIcon: IconSettings = {
+ dmi: 'icons/mob/huds/hud.dmi',
+ transform: 'scale(2.3) translateX(8px) translateY(1px)',
+};
+
+const antagIcon: IconSettings = {
+ dmi: 'icons/mob/huds/antag_hud.dmi',
+ transform: 'scale(1.8) translateX(-16px) translateY(7px)',
+};
+
+export function JobIcon(props: Props) {
+ const { item } = props;
+
+ let iconSettings: IconSettings;
+ if ('antag' in item) {
+ iconSettings = antagIcon;
+ } else {
+ iconSettings = normalIcon;
+ }
+
+ // We don't need to cast here but typescript isn't smart enough to know that
+ const { icon = '', job = '' } = item;
+
+ return (
+
+ {icon === 'borg' ? (
+
+ ) : (
+
+
+
+ )}
+
+ );
+}
diff --git a/tgui/packages/tgui/interfaces/Orbit/OrbitBlade.tsx b/tgui/packages/tgui/interfaces/Orbit/OrbitBlade.tsx
new file mode 100644
index 00000000000..07c5a1c08f6
--- /dev/null
+++ b/tgui/packages/tgui/interfaces/Orbit/OrbitBlade.tsx
@@ -0,0 +1,188 @@
+import { capitalizeFirst, toTitleCase } from 'common/string';
+import { useContext } from 'react';
+
+import { useBackend } from '../../backend';
+import {
+ Button,
+ Icon,
+ ProgressBar,
+ Section,
+ Stack,
+ Tooltip,
+} from '../../components';
+import { OrbitContext } from '.';
+import { HEALTH, VIEWMODE } from './constants';
+import { getDepartmentByJob, getDisplayName } from './helpers';
+import { JobIcon } from './JobIcon';
+import { OrbitData } from './types';
+
+/** Slide open menu with more info about the current observable */
+export function OrbitBlade(props) {
+ const { data } = useBackend();
+ const { orbiting } = data;
+
+ const { setBladeOpen } = useContext(OrbitContext);
+
+ return (
+
+
+ setBladeOpen(false)}
+ />
+ }
+ color="label"
+ title="Orbit Settings"
+ >
+ Keep in mind: Orbit does not update automatically. You will need to
+ click the "Refresh" button to see the latest data.
+
+
+
+
+
+ {!!orbiting && (
+
+
+
+ )}
+
+ );
+}
+
+function ViewModeSelector(props) {
+ const { viewMode, setViewMode } = useContext(OrbitContext);
+
+ return (
+
+
+
+ Change the color and sorting scheme of observable items.
+
+
+ {Object.entries(VIEWMODE).map(([key, value]) => (
+
+ ))}
+
+
+ );
+}
+
+function OrbitInfo(props) {
+ const { data } = useBackend();
+
+ const { orbiting } = data;
+ if (!orbiting) return;
+
+ const { name, full_name, health, job } = orbiting;
+
+ let department;
+ if ('job' in orbiting && !!job) {
+ department = getDepartmentByJob(job);
+ }
+
+ let showAFK;
+ if ('client' in orbiting && !orbiting.client) {
+ showAFK = true;
+ }
+
+ return (
+
+
+
+ {toTitleCase(getDisplayName(full_name, name))}
+ {showAFK && (
+
+
+
+ )}
+
+
+ {!!job && (
+
+
+
+
+
+
+ {job}
+
+ {!!department && (
+
+ {capitalizeFirst(department)}
+
+ )}
+
+
+ )}
+ {health !== undefined && (
+
+
+
+ )}
+
+
+
+
+ );
+}
+
+function HealthDisplay(props: { health: number }) {
+ const { health } = props;
+
+ let icon = 'heart';
+ let howDead;
+ switch (true) {
+ case health <= HEALTH.Ruined:
+ howDead = `Very Dead: ${health}`;
+ icon = 'skull';
+ break;
+ case health <= HEALTH.Dead:
+ howDead = `Dead: ${health}`;
+ icon = 'heart-broken';
+ break;
+ case health <= HEALTH.Crit:
+ howDead = `Health critical: ${health}`;
+ icon = 'tired';
+ break;
+ case health <= HEALTH.Bad:
+ howDead = `Bad: ${health}`;
+ icon = 'heartbeat';
+ break;
+ }
+
+ return (
+
+
+
+
+
+ {howDead || (
+
+ )}
+
+
+ );
+}
diff --git a/tgui/packages/tgui/interfaces/Orbit/OrbitCollapsible.tsx b/tgui/packages/tgui/interfaces/Orbit/OrbitCollapsible.tsx
new file mode 100644
index 00000000000..fa5e78bd1f7
--- /dev/null
+++ b/tgui/packages/tgui/interfaces/Orbit/OrbitCollapsible.tsx
@@ -0,0 +1,80 @@
+import { useContext } from 'react';
+
+import { Collapsible, Flex, Tooltip } from '../../components';
+import { OrbitContext } from '.';
+import { VIEWMODE } from './constants';
+import {
+ isJobOrNameMatch,
+ sortByDepartment,
+ sortByDisplayName,
+} from './helpers';
+import { OrbitItem } from './OrbitItem';
+import { OrbitTooltip } from './OrbitTooltip';
+import { Observable } from './types';
+
+type Props = {
+ color?: string;
+ section: Observable[];
+ title: string;
+};
+
+/**
+ * Displays a collapsible with a map of observable items.
+ * Filters the results if there is a provided search query.
+ */
+export function OrbitCollapsible(props: Props) {
+ const { color, section = [], title } = props;
+
+ const { autoObserve, searchQuery, viewMode } = useContext(OrbitContext);
+
+ const filteredSection = section.filter((observable) =>
+ isJobOrNameMatch(observable, searchQuery),
+ );
+
+ if (viewMode === VIEWMODE.Department) {
+ filteredSection.sort(sortByDepartment);
+ } else {
+ filteredSection.sort(sortByDisplayName);
+ }
+
+ if (!filteredSection.length) {
+ return;
+ }
+
+ return (
+
+
+ {filteredSection.map((item) => {
+ const content = (
+
+ );
+
+ if (!item.health && !item.extra) {
+ return content;
+ }
+
+ return (
+ }
+ key={item.ref}
+ position="bottom-start"
+ >
+ {content}
+
+ );
+ })}
+
+
+ );
+}
diff --git a/tgui/packages/tgui/interfaces/Orbit/OrbitContent.tsx b/tgui/packages/tgui/interfaces/Orbit/OrbitContent.tsx
new file mode 100644
index 00000000000..91502644faa
--- /dev/null
+++ b/tgui/packages/tgui/interfaces/Orbit/OrbitContent.tsx
@@ -0,0 +1,99 @@
+import { toTitleCase } from 'common/string';
+
+import { useBackend } from '../../backend';
+import { NoticeBox, Section, Stack, Table, Tooltip } from '../../components';
+import { ANTAG2COLOR } from './constants';
+import { getAntagCategories } from './helpers';
+import { OrbitCollapsible } from './OrbitCollapsible';
+import { AntagGroup, Observable, OrbitData } from './types';
+
+type ContentSection = {
+ content: Observable[];
+ title: string;
+ color?: string;
+};
+
+/**
+ * The primary content display for points of interest.
+ * Renders a scrollable section replete collapsibles for each
+ * observable group.
+ */
+export function OrbitContent(props) {
+ const { act, data } = useBackend();
+ const { antagonists = [], critical = [] } = data;
+
+ let antagGroups: AntagGroup[] = [];
+ if (antagonists.length) {
+ antagGroups = getAntagCategories(antagonists);
+ }
+
+ const sections: readonly ContentSection[] = [
+ {
+ color: 'purple',
+ content: data.deadchat_controlled,
+ title: 'Deadchat Controlled',
+ },
+ {
+ color: 'blue',
+ content: data.alive,
+ title: 'Alive',
+ },
+ {
+ content: data.dead,
+ title: 'Dead',
+ },
+ {
+ content: data.ghosts,
+ title: 'Ghosts',
+ },
+ {
+ content: data.misc,
+ title: 'Misc',
+ },
+ {
+ content: data.npcs,
+ title: 'NPCs',
+ },
+ ];
+
+ return (
+
+
+ {critical.map((crit) => (
+
+ act('orbit', { ref: crit.ref })}
+ >
+
+
+ {toTitleCase(crit.full_name)}
+ {crit.extra}
+
+
+
+
+ ))}
+
+ {antagGroups.map(([title, members]) => (
+
+ ))}
+
+ {sections.map((section) => (
+
+ ))}
+
+
+ );
+}
diff --git a/tgui/packages/tgui/interfaces/Orbit/OrbitItem.tsx b/tgui/packages/tgui/interfaces/Orbit/OrbitItem.tsx
new file mode 100644
index 00000000000..992904988ec
--- /dev/null
+++ b/tgui/packages/tgui/interfaces/Orbit/OrbitItem.tsx
@@ -0,0 +1,57 @@
+import { capitalizeFirst } from 'common/string';
+
+import { useBackend } from '../../backend';
+import { Button, Flex, Icon, Stack } from '../../components';
+import { getDisplayColor, getDisplayName } from './helpers';
+import { JobIcon } from './JobIcon';
+import { Antagonist, Observable, OrbitData, ViewMode } from './types';
+
+type Props = {
+ item: Observable | Antagonist;
+ autoObserve: boolean;
+ viewMode: ViewMode;
+ color: string | undefined;
+};
+
+/** Each button on the observable section */
+export function OrbitItem(props: Props) {
+ const { item, autoObserve, viewMode, color } = props;
+ const { full_name, icon, job, name, orbiters, ref } = item;
+
+ const { act, data } = useBackend();
+ const { orbiting } = data;
+
+ const selected = ref === orbiting?.ref;
+ const validIcon = !!job && !!icon && icon !== 'hudunknown';
+
+ return (
+ act('orbit', { auto_observe: autoObserve, ref })}
+ style={{
+ display: 'flex',
+ }}
+ >
+ {validIcon && }
+
+
+
+ );
+}
diff --git a/tgui/packages/tgui/interfaces/Orbit/OrbitSearchBar.tsx b/tgui/packages/tgui/interfaces/Orbit/OrbitSearchBar.tsx
new file mode 100644
index 00000000000..c83512c39c7
--- /dev/null
+++ b/tgui/packages/tgui/interfaces/Orbit/OrbitSearchBar.tsx
@@ -0,0 +1,119 @@
+import { useContext } from 'react';
+
+import { useBackend } from '../../backend';
+import { Button, Icon, Input, Section, Stack } from '../../components';
+import { OrbitContext } from '.';
+import { VIEWMODE } from './constants';
+import { isJobOrNameMatch, sortByOrbiters } from './helpers';
+import { OrbitData } from './types';
+
+/** Search bar for the orbit ui. Has a few buttons to switch between view modes and auto-observe */
+export function OrbitSearchBar(props) {
+ const {
+ autoObserve,
+ bladeOpen,
+ searchQuery,
+ viewMode,
+ setAutoObserve,
+ setBladeOpen,
+ setSearchQuery,
+ setViewMode,
+ } = useContext(OrbitContext);
+
+ const { act, data } = useBackend();
+
+ /** Gets a list of Observables, then filters the most relevant to orbit */
+ function orbitMostRelevant() {
+ const mostRelevant = [
+ data.alive,
+ data.antagonists,
+ data.critical,
+ data.deadchat_controlled,
+ data.dead,
+ data.ghosts,
+ data.misc,
+ data.npcs,
+ ]
+ .flat()
+ .filter((observable) => isJobOrNameMatch(observable, searchQuery))
+ .sort(sortByOrbiters)[0];
+
+ if (mostRelevant !== undefined) {
+ act('orbit', {
+ ref: mostRelevant.ref,
+ auto_observe: autoObserve,
+ });
+ }
+ }
+
+ /** Iterates through the view modes and switches to the next one */
+ function swapViewMode() {
+ const thisIndex = Object.values(VIEWMODE).indexOf(viewMode);
+ const nextIndex = (thisIndex + 1) % Object.values(VIEWMODE).length;
+
+ setViewMode(Object.values(VIEWMODE)[nextIndex]);
+ }
+
+ const viewModeTitle = Object.entries(VIEWMODE).find(
+ ([_key, value]) => value === viewMode,
+ )?.[0];
+
+ return (
+
+ );
+}
diff --git a/tgui/packages/tgui/interfaces/Orbit/OrbitTooltip.tsx b/tgui/packages/tgui/interfaces/Orbit/OrbitTooltip.tsx
new file mode 100644
index 00000000000..6c06838be4f
--- /dev/null
+++ b/tgui/packages/tgui/interfaces/Orbit/OrbitTooltip.tsx
@@ -0,0 +1,54 @@
+import { LabeledList, NoticeBox } from '../../components';
+import { Antagonist, Observable } from './types';
+
+type Props = {
+ item: Observable | Antagonist;
+};
+
+/** Displays some info on the mob as a tooltip. */
+export function OrbitTooltip(props: Props) {
+ const { item } = props;
+ const { extra, full_name, health, job } = item;
+
+ let antag;
+ if ('antag' in item) {
+ antag = item.antag;
+ }
+
+ const extraInfo = extra?.split(':');
+ const displayHealth = !!health && health >= 0 ? `${health}%` : 'Critical';
+ const showAFK = 'client' in item && !item.client;
+
+ return (
+ <>
+
+ Last Known Data
+
+
+ {extraInfo ? (
+
+ {extraInfo[1]}
+
+ ) : (
+ <>
+ {!!full_name && (
+ {full_name}
+ )}
+ {!!job && !antag && (
+ {job}
+ )}
+ {!!antag && (
+ {antag}
+ )}
+ {!!health && (
+
+ {displayHealth}
+
+ )}
+ >
+ )}
+ {showAFK && Away}
+
+ >
+ );
+}
diff --git a/tgui/packages/tgui/interfaces/Orbit/constants.ts b/tgui/packages/tgui/interfaces/Orbit/constants.ts
index 2ef6b605eb6..d683561f382 100644
--- a/tgui/packages/tgui/interfaces/Orbit/constants.ts
+++ b/tgui/packages/tgui/interfaces/Orbit/constants.ts
@@ -13,6 +13,60 @@ export const ANTAG2COLOR = {
'Invasive Overgrowth': 'green',
} as const;
+type Department = {
+ color: string;
+ trims: string[];
+};
+
+export const DEPARTMENT2COLOR: Record = {
+ cargo: {
+ color: 'brown',
+ trims: ['Bitrunner', 'Cargo Technician', 'Shaft Miner', 'Quartermaster'],
+ },
+ command: {
+ color: 'blue',
+ trims: ['Captain', 'Head of Personnel'],
+ },
+ engineering: {
+ color: 'orange',
+ trims: ['Atmospheric Technician', 'Chief Engineer', 'Station Engineer'],
+ },
+ medical: {
+ color: 'teal',
+ trims: [
+ 'Chemist',
+ 'Chief Medical Officer',
+ 'Coroner',
+ 'Medical Doctor',
+ 'Paramedic',
+ ],
+ },
+ science: {
+ color: 'pink',
+ trims: ['Geneticist', 'Research Director', 'Roboticist', 'Scientist'],
+ },
+ security: {
+ color: 'red',
+ trims: ['Detective', 'Head of Security', 'Security Officer', 'Warden'],
+ },
+ service: {
+ color: 'green',
+ trims: [
+ 'Bartender',
+ 'Botanist',
+ 'Chaplain',
+ 'Chef',
+ 'Clown',
+ 'Cook',
+ 'Curator',
+ 'Janitor',
+ 'Lawyer',
+ 'Mime',
+ 'Psychologist',
+ ],
+ },
+};
+
export const THREAT = {
Low: 1,
Medium: 5,
@@ -22,4 +76,14 @@ export const THREAT = {
export const HEALTH = {
Good: 69, // nice
Average: 19,
+ Bad: 0,
+ Crit: -30,
+ Dead: -100,
+ Ruined: -200,
+} as const;
+
+export const VIEWMODE = {
+ Health: 'heart',
+ Orbiters: 'ghost',
+ Department: 'id-badge',
} as const;
diff --git a/tgui/packages/tgui/interfaces/Orbit/helpers.ts b/tgui/packages/tgui/interfaces/Orbit/helpers.ts
index c0668dd02d8..9263ea59961 100644
--- a/tgui/packages/tgui/interfaces/Orbit/helpers.ts
+++ b/tgui/packages/tgui/interfaces/Orbit/helpers.ts
@@ -1,28 +1,34 @@
-import { filter, sortBy } from 'common/collections';
-
-import { HEALTH, THREAT } from './constants';
-import type { AntagGroup, Antagonist, Observable } from './types';
+import { DEPARTMENT2COLOR, HEALTH, THREAT, VIEWMODE } from './constants';
+import { AntagGroup, Antagonist, Observable, ViewMode } from './types';
/** Return a map of strings with each antag in its antag_category */
-export const getAntagCategories = (antagonists: Antagonist[]) => {
- const categories: Record = {};
+export function getAntagCategories(antagonists: Antagonist[]): AntagGroup[] {
+ const categories = new Map();
- antagonists.map((player) => {
+ for (const player of antagonists) {
const { antag_group } = player;
- if (!categories[antag_group]) {
- categories[antag_group] = [];
+ if (!categories.has(antag_group)) {
+ categories.set(antag_group, []);
}
+ categories.get(antag_group)!.push(player);
+ }
- categories[antag_group].push(player);
+ const sorted = Array.from(categories.entries()).sort((a, b) => {
+ const lowerA = a[0].toLowerCase();
+ const lowerB = b[0].toLowerCase();
+
+ if (lowerA < lowerB) return -1;
+ if (lowerA > lowerB) return 1;
+ return 0;
});
- return sortBy(Object.entries(categories), ([key]) => key);
-};
+ return sorted;
+}
/** Returns a disguised name in case the person is wearing someone else's ID */
-export const getDisplayName = (full_name: string, name?: string) => {
- if (!name) {
+export function getDisplayName(full_name: string, nickname?: string): string {
+ if (!nickname) {
return full_name;
}
@@ -31,34 +37,36 @@ export const getDisplayName = (full_name: string, name?: string) => {
full_name.match(/\(as /) ||
full_name.match(/^Unknown/)
) {
- return name;
+ return nickname;
}
// return only the name before the first ' [' or ' ('
return `"${full_name.split(/ \[| \(/)[0]}"`;
-};
+}
-export const getMostRelevant = (
- searchQuery: string,
- observables: Observable[][],
-): Observable => {
- const queriedObservables =
- // Sorts descending by orbiters
- sortBy(
- // Filters out anything that doesn't match search
- filter(
- observables
- // Makes a single Observables list for an easy search
- .flat(),
- (observable) => isJobOrNameMatch(observable, searchQuery),
- ),
- (observable) => -(observable.orbiters || 0),
- );
- return queriedObservables[0];
-};
+/** Returns the department the player is in */
+export function getDepartmentByJob(job: string): string | undefined {
+ const withoutParenthesis = job.replace(/ \(.*\)/, '');
+
+ for (const department in DEPARTMENT2COLOR) {
+ if (DEPARTMENT2COLOR[department].trims.includes(withoutParenthesis)) {
+ return department;
+ }
+ }
+}
+
+/** Gets department color for a job */
+function getDepartmentColor(job: string | undefined): string {
+ if (!job) return 'grey';
+
+ const department = getDepartmentByJob(job);
+ if (!department) return 'grey';
+
+ return DEPARTMENT2COLOR[department].color;
+}
/** Returns the display color for certain health percentages */
-const getHealthColor = (health: number) => {
+function getHealthColor(health: number): string {
switch (true) {
case health > HEALTH.Good:
return 'good';
@@ -67,10 +75,10 @@ const getHealthColor = (health: number) => {
default:
return 'bad';
}
-};
+}
/** Returns the display color based on orbiter numbers */
-const getThreatColor = (orbiters = 0) => {
+function getThreatColor(orbiters = 0): string {
switch (true) {
case orbiters > THREAT.High:
return 'violet';
@@ -81,32 +89,43 @@ const getThreatColor = (orbiters = 0) => {
default:
return 'good';
}
-};
+}
/** Displays color for buttons based on the health or orbiter count. */
-export const getDisplayColor = (
+export function getDisplayColor(
item: Observable,
- heatMap: boolean,
- color?: string,
-) => {
- const { health, orbiters } = item;
+ mode: ViewMode,
+ override?: string,
+): string {
+ const { job, health, orbiters } = item;
+
+ // Things like blob camera, etc
if (typeof health !== 'number') {
- return color ? 'good' : 'grey';
+ return override ? 'good' : 'grey';
}
- if (heatMap) {
- return getThreatColor(orbiters);
+
+ // Players that are AFK
+ if ('client' in item && !item.client) {
+ return 'grey';
}
- return getHealthColor(health);
-};
+
+ switch (mode) {
+ case VIEWMODE.Orbiters:
+ return getThreatColor(orbiters);
+ case VIEWMODE.Department:
+ return getDepartmentColor(job);
+ default:
+ return getHealthColor(health);
+ }
+}
/** Checks if a full name or job title matches the search. */
-export const isJobOrNameMatch = (
+export function isJobOrNameMatch(
observable: Observable,
searchQuery: string,
-) => {
- if (!searchQuery) {
- return true;
- }
+): boolean {
+ if (!searchQuery) return true;
+
const { full_name, job } = observable;
return (
@@ -114,4 +133,46 @@ export const isJobOrNameMatch = (
job?.toLowerCase().includes(searchQuery?.toLowerCase()) ||
false
);
-};
+}
+
+/** Sorts by department */
+export function sortByDepartment(poiA: Observable, poiB: Observable): number {
+ const departmentA = (poiA.job && getDepartmentByJob(poiA.job)) || 'unknown';
+ const departmentB = (poiB.job && getDepartmentByJob(poiB.job)) || 'unknown';
+
+ if (departmentA < departmentB) return -1;
+ if (departmentA > departmentB) return 1;
+ return 0;
+}
+
+/** Sorts based on real name */
+export function sortByDisplayName(poiA: Observable, poiB: Observable): number {
+ const nameA = getDisplayName(poiA.full_name, poiA.name)
+ .replace(/^"/, '')
+ .toLowerCase();
+ const nameB = getDisplayName(poiB.full_name, poiB.name)
+ .replace(/^"/, '')
+ .toLowerCase();
+
+ if (nameA < nameB) {
+ return -1;
+ }
+ if (nameA > nameB) {
+ return 1;
+ }
+ return 0;
+}
+
+/** Sorts by most orbiters */
+export function sortByOrbiters(poiA: Observable, poiB: Observable): number {
+ const orbitersA = poiA.orbiters || 0;
+ const orbitersB = poiB.orbiters || 0;
+
+ if (orbitersA < orbitersB) {
+ return -1;
+ }
+ if (orbitersA > orbitersB) {
+ return 1;
+ }
+ return 0;
+}
diff --git a/tgui/packages/tgui/interfaces/Orbit/index.tsx b/tgui/packages/tgui/interfaces/Orbit/index.tsx
index c58e0963ff4..d537548107c 100644
--- a/tgui/packages/tgui/interfaces/Orbit/index.tsx
+++ b/tgui/packages/tgui/interfaces/Orbit/index.tsx
@@ -1,306 +1,68 @@
-import { filter, sortBy } from 'common/collections';
-import { capitalizeFirst } from 'common/string';
-import { useBackend, useLocalState } from 'tgui/backend';
-import {
- Button,
- Collapsible,
- Icon,
- Input,
- LabeledList,
- NoticeBox,
- Section,
- Stack,
-} from 'tgui/components';
+import { createContext, Dispatch, SetStateAction, useState } from 'react';
+import { Stack } from 'tgui/components';
import { Window } from 'tgui/layouts';
-import { JOB2ICON } from '../common/JobToIcon';
-import { ANTAG2COLOR } from './constants';
-import {
- getAntagCategories,
- getDisplayColor,
- getDisplayName,
- getMostRelevant,
- isJobOrNameMatch,
-} from './helpers';
-import type { AntagGroup, Antagonist, Observable, OrbitData } from './types';
+import { VIEWMODE } from './constants';
+import { OrbitBlade } from './OrbitBlade';
+import { OrbitContent } from './OrbitContent';
+import { OrbitSearchBar } from './OrbitSearchBar';
+import { ViewMode } from './types';
-export const Orbit = (props) => {
- return (
-
-
-
-
-
-
-
-
-
-
-
-
- );
-};
+export function Orbit(props) {
+ const [autoObserve, setAutoObserve] = useState(false);
+ const [bladeOpen, setBladeOpen] = useState(false);
+ const [searchQuery, setSearchQuery] = useState('');
+ const [viewMode, setViewMode] = useState(VIEWMODE.Health);
-/** Controls filtering out the list of observables via search */
-const ObservableSearch = (props) => {
- const { act, data } = useBackend();
- const {
- alive = [],
- antagonists = [],
- deadchat_controlled = [],
- dead = [],
- ghosts = [],
- misc = [],
- npcs = [],
- } = data;
-
- const [autoObserve, setAutoObserve] = useLocalState(
- 'autoObserve',
- false,
- );
- const [heatMap, setHeatMap] = useLocalState('heatMap', false);
- const [searchQuery, setSearchQuery] = useLocalState(
- 'searchQuery',
- '',
- );
-
- /** Gets a list of Observables, then filters the most relevant to orbit */
- const orbitMostRelevant = (searchQuery: string) => {
- const mostRelevant = getMostRelevant(searchQuery, [
- alive,
- antagonists,
- deadchat_controlled,
- dead,
- ghosts,
- misc,
- npcs,
- ]);
-
- if (mostRelevant !== undefined) {
- act('orbit', {
- ref: mostRelevant.ref,
- auto_observe: autoObserve,
- });
- }
- };
+ const dynamicWidth = bladeOpen ? 650 : 400;
return (
-
- );
-};
-
-/**
- * The primary content display for points of interest.
- * Renders a scrollable section replete with subsections for each
- * observable group.
- */
-const ObservableContent = (props) => {
- const { data } = useBackend();
- const {
- alive = [],
- antagonists = [],
- deadchat_controlled = [],
- dead = [],
- ghosts = [],
- misc = [],
- npcs = [],
- } = data;
-
- let collatedAntagonists: AntagGroup[] = [];
-
- if (antagonists.length) {
- collatedAntagonists = getAntagCategories(antagonists);
- }
-
- return (
-
- {collatedAntagonists?.map(([title, antagonists]) => {
- return (
-
- );
- })}
-
-
-
-
-
-
-
- );
-};
-
-/**
- * Displays a collapsible with a map of observable items.
- * Filters the results if there is a provided search query.
- */
-const ObservableSection = (props: {
- color?: string;
- section: Observable[];
- title: string;
-}) => {
- const { color, section = [], title } = props;
-
- if (!section.length) {
- return null;
- }
-
- const [searchQuery] = useLocalState('searchQuery', '');
-
- const filteredSection = sortBy(
- filter(section, (observable) => isJobOrNameMatch(observable, searchQuery)),
- (observable) =>
- getDisplayName(observable.full_name, observable.name)
- .replace(/^"/, '')
- .toLowerCase(),
- );
-
- if (!filteredSection.length) {
- return null;
- }
-
- return (
-
-
- {filteredSection.map((poi, index) => {
- return ;
- })}
-
-
- );
-};
-
-/** Renders an observable button that has tooltip info for living Observables*/
-const ObservableItem = (props: { color?: string; item: Observable }) => {
- const { act } = useBackend();
- const { color, item } = props;
- const { extra, full_name, job, health, name, orbiters, ref } = item;
-
- const [autoObserve] = useLocalState('autoObserve', false);
- const [heatMap] = useLocalState('heatMap', false);
-
- return (
-
- );
-};
-
-/** Displays some info on the mob as a tooltip. */
-const ObservableTooltip = (props: { item: Observable | Antagonist }) => {
- const { item } = props;
- const { extra, full_name, health, job } = item;
- let antag;
- if ('antag' in item) {
- antag = item.antag;
- }
-
- const extraInfo = extra?.split(':');
- const displayHealth = !!health && health >= 0 ? `${health}%` : 'Critical';
-
- return (
- <>
-
- Last Known Data
-
-
- {extraInfo ? (
-
- {extraInfo[1]}
-
- ) : (
- <>
- {!!full_name && (
- {full_name}
- )}
- {!!job && !antag && (
- {job}
- )}
- {!!antag && (
- {antag}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {bladeOpen && (
+
+
+
)}
- {!!health && (
-
- {displayHealth}
-
- )}
- >
- )}
-
- >
+
+
+
+
);
+}
+
+type Context = {
+ autoObserve: boolean;
+ setAutoObserve: Dispatch>;
+ bladeOpen: boolean;
+ setBladeOpen: Dispatch>;
+ searchQuery: string;
+ setSearchQuery: Dispatch>;
+ viewMode: ViewMode;
+ setViewMode: Dispatch>;
};
+
+export const OrbitContext = createContext({} as Context);
diff --git a/tgui/packages/tgui/interfaces/Orbit/types.ts b/tgui/packages/tgui/interfaces/Orbit/types.ts
index dfbcf4afa61..ffeea321a3c 100644
--- a/tgui/packages/tgui/interfaces/Orbit/types.ts
+++ b/tgui/packages/tgui/interfaces/Orbit/types.ts
@@ -1,3 +1,7 @@
+import { BooleanLike } from 'common/react';
+
+import { VIEWMODE } from './constants';
+
export type Antagonist = Observable & { antag: string; antag_group: string };
export type AntagGroup = [string, Antagonist[]];
@@ -5,19 +9,33 @@ export type AntagGroup = [string, Antagonist[]];
export type OrbitData = {
alive: Observable[];
antagonists: Antagonist[];
+ critical: Critical[];
dead: Observable[];
deadchat_controlled: Observable[];
ghosts: Observable[];
misc: Observable[];
npcs: Observable[];
+ orbiting: Observable | null;
};
export type Observable = {
- extra?: string;
full_name: string;
- health?: number;
- job?: string;
- name?: string;
- orbiters?: number;
+ ref: string;
+ // Optionals
+} & Partial<{
+ client: BooleanLike;
+ extra: string;
+ health: number;
+ icon: string;
+ job: string;
+ name: string;
+ orbiters: number;
+}>;
+
+type Critical = {
+ extra: string;
+ full_name: string;
ref: string;
};
+
+export type ViewMode = (typeof VIEWMODE)[keyof typeof VIEWMODE];
diff --git a/tgui/packages/tgui/styles/interfaces/Orbit.scss b/tgui/packages/tgui/styles/interfaces/Orbit.scss
new file mode 100644
index 00000000000..2bf2132fa52
--- /dev/null
+++ b/tgui/packages/tgui/styles/interfaces/Orbit.scss
@@ -0,0 +1,22 @@
+.JobIcon {
+ align-items: center;
+ background: black;
+ display: flex;
+ height: 20px;
+ justify-content: center;
+ margin-right: -1px;
+ padding-left: 2px;
+ overflow: hidden;
+}
+
+.OrbitItem__selected {
+ position: absolute;
+ top: 0;
+ right: 0;
+ width: 0;
+ height: 0;
+ border-left: 5px solid transparent;
+ border-right: 5px solid transparent;
+ border-top: 5px solid white;
+ transform: translateX(-50%);
+}
diff --git a/tgui/packages/tgui/styles/main.scss b/tgui/packages/tgui/styles/main.scss
index b2d0b4afd12..99e32921a37 100644
--- a/tgui/packages/tgui/styles/main.scss
+++ b/tgui/packages/tgui/styles/main.scss
@@ -66,6 +66,7 @@
@include meta.load-css('./interfaces/NtosMessenger.scss');
@include meta.load-css('./interfaces/NtosNotepad.scss');
@include meta.load-css('./interfaces/NuclearBomb.scss');
+@include meta.load-css('./interfaces/Orbit.scss');
@include meta.load-css('./interfaces/Paper.scss');
@include meta.load-css('./interfaces/PersonalCrafting.scss');
@include meta.load-css('./interfaces/PreferencesMenu.scss');