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 ( +
+ + + + + + setSearchQuery(value)} + placeholder="Search..." + value={searchQuery} + /> + + + +
+ ); +} 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 ( -
- - - - - - orbitMostRelevant(value)} - onInput={(event, value) => setSearchQuery(value)} - placeholder="Search..." - value={searchQuery} - /> - - - -
- ); -}; - -/** - * 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');